Skip to content

useLiveSuspenseQuery doesn't work with ErrorBoundary #1343

@DeluxeOwl

Description

@DeluxeOwl
  • 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
    }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions