TanStack / TanStack/db

`useLiveSuspenseQuery` doesn't work with `ErrorBoundary`

Open
#1,343 0 comments 4 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
3.9k
Forks
266
Avg merge
1d 4h
Merged PRs (30d)
55

Description

  • I've validated the bug against the latest version of DB packages

Describe the bug
It's not clear what the interaction between useLiveSuspenseQuery and ErrorBoundary is supposed to look like. Currently, if the fetch function throws, it isnt caught by the ErrorBoundary

To Reproduce
Steps to reproduce the behavior:

https://stackblitz.com/edit/vitejs-vite-w4tkecqk?file=src%2FApp.tsx

Copy pasted snippet of code
import React, { Suspense, useState } from 'react';
import { createCollection, useLiveSuspenseQuery } from '@tanstack/react-db';
import { queryCollectionOptions } from '@tanstack/query-db-collection';
import { QueryClient } from '@tanstack/query-core';
import { ErrorBoundary } from 'react-error-boundary';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: number;
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

let mockDb: Todo[] = [
  {
    id: '1',
    text: 'Learn TanStack DB',
    completed: false,
    createdAt: Date.now(),
  },
];

const api = {
  getTodos: async (): Promise<Todo[]> => {
    await sleep(1000);
    throw new Error('random error');
    // Uncomment this for normal usage.
    // return [...mockDb];
  },
  addTodo: async (todo: Todo): Promise<Todo> => {
    await sleep(600);
    mockDb.push(todo);
    return todo;
  },
  updateTodo: async (id: string, updates: Partial<Todo>): Promise<Todo> => {
    await sleep(800);
    const index = mockDb.findIndex((t) => t.id === id);
    mockDb[index] = { ...mockDb[index], ...updates };
    return mockDb[index];
  },
};

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
      refetchOnWindowFocus: false,
      throwOnError: true,
    },
  },
});

const todoCollection = createCollection(
  queryCollectionOptions({
    queryClient: queryClient,
    queryKey: ['todos'],
    queryFn: () => api.getTodos(),
    getKey: (todo: Todo) => todo.id,
    onUpdate: async ({ transaction }) => {
      // The transaction contains the modified data
      const { modified } = transaction.mutations[0];
      await api.updateTodo(modified.id, modified);
    },
    onInsert: async ({ transaction }) => {
      // The transaction contains the modified data
      const { modified } = transaction.mutations[0];
      await api.addTodo(modified);
    },
  })
);

function TodoList() {
  const [text, setText] = useState('');

  const { data: todos = [] } = useLiveSuspenseQuery((q) =>
    q
      .from({ todo: todoCollection })
      .orderBy(({ todo }) => todo.createdAt, 'desc')
  );

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!text.trim()) return;

    const newTodo: Todo = {
      id: Math.random().toString(36).slice(2, 9),
      text,
      completed: false,
      createdAt: Date.now(),
    };

    // collection.insert updates the UI state instantly
    todoCollection.insert(newTodo);
    setText('');
  };

  const toggleTodo = (todo: Todo) => {
    todoCollection.update(todo.id, (draft) => {
      draft.completed = !draft.completed;
    });
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="What needs to be done?"
        />
        <button type="submit">Add Todo</button>
      </form>

      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            onClick={() => toggleTodo(todo)}
            style={{
              cursor: 'pointer',
              textDecoration: todo.completed ? 'line-through' : 'none',
            }}
          >
            {todo.text} {todo.completed ? '✅' : '⭕'}
          </li>
        ))}
      </ul>
    </div>
  );
}

export default function App() {
  return (
    <div>
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <Suspense fallback={<p>Loading...</p>}>
          <TodoList />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}
package.json
{
  "name": "vite-react-typescript-starter",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@tanstack/query-db-collection": "^1.0.28",
    "@tanstack/react-db": "^0.1.75",
    "@tanstack/react-query": "^5.90.21",
    "react": "^19.2.4",
    "react-dom": "^19.2.4",
    "react-error-boundary": "^6.1.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.39.3",
    "@types/node": "^24.11.0",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^5.1.4",
    "eslint": "^9.39.3",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.5.2",
    "globals": "^17.4.0",
    "typescript": "~5.9.3",
    "typescript-eslint": "^8.56.1",
    "vite": "^7.3.1"
  }
}

tl;dr

<ErrorBoundary fallback={<div>Something went wrong</div>}>
        <Suspense fallback={<p>Loading...</p>}>
          <TodoList />
        </Suspense>
      </ErrorBoundary>

// in TodoList
const { data: todos = [] } = useLiveSuspenseQuery((q) =>
    q
      .from({ todo: todoCollection })
      .orderBy(({ todo }) => todo.createdAt, 'desc')
  );

// in collection
const todoCollection = createCollection(
  queryCollectionOptions({
    queryClient: queryClient,
    queryKey: ['todos'],
    queryFn: () => api.getTodos(),

// api.getTodos()
const api = {
  getTodos: async (): Promise<Todo[]> => {
    await sleep(1000);
    throw new Error('random error');
    // Uncomment this for normal usage.
    // return [...mockDb];
  },

Expected behavior
I expected the error to be caught by the error boundary.

Workaround

Throwing the collection error if encountered:

    const { data:todos } = useLiveSuspenseQuery((q) => q.from({ todo: todoCollection }))

    if (todoCollection.utils.isError) {
        throw todoCollection.utils.lastError
    }

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running the linked StackBlitz reproduction and trace TodoList's useLiveSuspenseQuery through queryCollectionOptions and the api.getTodos fetcher. Compare the current behavior with the workaround using todoCollection.utils.lastError; done means the fetch failure reaches the surrounding ErrorBoundary, though no repository file or test is named.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.