Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
722c1e2
Add comprehensive Suspense support research document
claude Oct 20, 2025
65d2ce6
Update Suspense research with React 18 compatibility
claude Oct 20, 2025
1b34458
feat: Add useLiveSuspenseQuery hook for React Suspense support
claude Oct 20, 2025
da4899f
chore: Remove example docs (will be added to official docs separately)
claude Oct 20, 2025
bd428fc
chore: Add changeset for useLiveSuspenseQuery
claude Oct 20, 2025
a67b7f7
chore: Remove research document (internal reference only)
claude Oct 20, 2025
36b5c06
style: Run prettier formatting
claude Oct 20, 2025
69238e8
refactor: Refactor useLiveSuspenseQuery to wrap useLiveQuery
claude Oct 20, 2025
d6bcaeb
fix: Change changeset to patch release (pre-v1)
claude Oct 20, 2025
50c261b
fix: Fix TypeScript error and lint warning in useLiveSuspenseQuery
claude Oct 20, 2025
16a2163
fix: Address critical Suspense lifecycle bugs from code review
claude Oct 20, 2025
ed66008
test: Fix failing tests in useLiveSuspenseQuery
claude Oct 20, 2025
85841c2
docs: Add useLiveSuspenseQuery documentation
claude Oct 20, 2025
db1dc7e
docs: Clarify Suspense/ErrorBoundary section is React-only
claude Oct 20, 2025
3905895
docs: Add router loader pattern recommendation
claude Oct 20, 2025
1dd7e22
docs: Use more neutral language for Suspense vs traditional patterns
claude Oct 20, 2025
32a2960
chore: Update changeset with documentation additions
claude Oct 20, 2025
e3cbce4
Merge origin/main into claude/research-db-suspense-011CUK3kMRXvivgb6a…
claude Oct 21, 2025
50e5aad
test: Add coverage for pre-created SingleResult and StrictMode
claude Oct 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/suspense-query-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
"@tanstack/react-db": patch
---

Add `useLiveSuspenseQuery` hook for React Suspense support

Introduces a new `useLiveSuspenseQuery` hook that integrates with React Suspense and Error Boundaries, following TanStack Query's `useSuspenseQuery` pattern.

**Key features:**

- React 18+ compatible using the throw promise pattern
- Type-safe API with guaranteed data (never undefined)
- Automatic error handling via Error Boundaries
- Reactive updates after initial load via useSyncExternalStore
- Support for dependency-based re-suspension
- Works with query functions, config objects, and pre-created collections

**Example usage:**

```tsx
import { Suspense } from "react"
import { useLiveSuspenseQuery } from "@tanstack/react-db"

function TodoList() {
// Data is guaranteed to be defined - no isLoading needed
const { data } = useLiveSuspenseQuery((q) =>
q
.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.completed, false))
)

return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}

function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<TodoList />
</Suspense>
)
}
```

**Implementation details:**

- Throws promises when collection is loading (caught by Suspense)
- Throws errors when collection fails (caught by Error Boundary)
- Reuses promise across re-renders to prevent infinite loops
- Detects dependency changes and creates new collection/promise
- Same TypeScript overloads as useLiveQuery for consistency

**Documentation:**

- Comprehensive guide in live-queries.md covering usage patterns and when to use each hook
- Comparison with useLiveQuery showing different approaches to loading/error states
- Router loader pattern recommendation for React Router/TanStack Router users
- Error handling examples with Suspense and Error Boundaries

Resolves #692
30 changes: 30 additions & 0 deletions docs/guides/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,36 @@ Collection status values:
- `error` - In error state
- `cleaned-up` - Cleaned up and no longer usable

### Using Suspense and Error Boundaries (React)

For React applications, you can handle loading and error states with `useLiveSuspenseQuery`, React Suspense, and Error Boundaries:

```tsx
import { useLiveSuspenseQuery } from "@tanstack/react-db"
import { Suspense } from "react"
import { ErrorBoundary } from "react-error-boundary"

const TodoList = () => {
// No need to check status - Suspense and ErrorBoundary handle it
const { data } = useLiveSuspenseQuery(
(query) => query.from({ todos: todoCollection })
)

// data is always defined here
return <div>{data.map(todo => <div key={todo.id}>{todo.text}</div>)}</div>
}

const App = () => (
<ErrorBoundary fallback={<div>Failed to load todos</div>}>
<Suspense fallback={<div>Loading...</div>}>
<TodoList />
</Suspense>
</ErrorBoundary>
)
```

With this approach, loading states are handled by `<Suspense>` and error states are handled by `<ErrorBoundary>` instead of within your component logic. See the [React Suspense section in Live Queries](../live-queries#using-with-react-suspense) for more details.

## Transaction Error Handling

When mutations fail, TanStack DB automatically rolls back optimistic updates:
Expand Down
175 changes: 175 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,181 @@ export class UserListComponent {

For more details on framework integration, see the [React](../../framework/react/adapter), [Vue](../../framework/vue/adapter), and [Angular](../../framework/angular/adapter) adapter documentation.

### Using with React Suspense

For React applications, you can use the `useLiveSuspenseQuery` hook to integrate with React Suspense boundaries. This hook suspends rendering while data loads initially, then streams updates without re-suspending.

```tsx
import { useLiveSuspenseQuery } from '@tanstack/react-db'
import { Suspense } from 'react'

function UserList() {
// This will suspend until data is ready
const { data } = useLiveSuspenseQuery((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
)

// data is always defined - no need for optional chaining
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}

function App() {
return (
<Suspense fallback={<div>Loading users...</div>}>
<UserList />
</Suspense>
)
}
```

#### Type Safety

The key difference from `useLiveQuery` is that `data` is always defined (never `undefined`). The hook suspends during initial load, so by the time your component renders, data is guaranteed to be available:

```tsx
function UserStats() {
const { data } = useLiveSuspenseQuery((q) =>
q.from({ user: usersCollection })
)

// TypeScript knows data is Array<User>, not Array<User> | undefined
return <div>Total users: {data.length}</div>
}
```

#### Error Handling

Combine with Error Boundaries to handle loading errors:

```tsx
import { ErrorBoundary } from 'react-error-boundary'

function App() {
return (
<ErrorBoundary fallback={<div>Failed to load users</div>}>
<Suspense fallback={<div>Loading users...</div>}>
<UserList />
</Suspense>
</ErrorBoundary>
)
}
```

#### Reactive Updates

After the initial load, data updates stream in without re-suspending:

```tsx
function UserList() {
const { data } = useLiveSuspenseQuery((q) =>
q.from({ user: usersCollection })
)

// Suspends once during initial load
// After that, data updates automatically when users change
// UI never re-suspends for live updates
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
```

#### Re-suspending on Dependency Changes

When dependencies change, the hook re-suspends to load new data:

```tsx
function FilteredUsers({ minAge }: { minAge: number }) {
const { data } = useLiveSuspenseQuery(
(q) =>
q
.from({ user: usersCollection })
.where(({ user }) => gt(user.age, minAge)),
[minAge] // Re-suspend when minAge changes
)

return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name} - {user.age}</li>
))}
</ul>
)
}
```

#### When to Use Which Hook

- **Use `useLiveSuspenseQuery`** when:
- You want to use React Suspense for loading states
- You prefer handling loading/error states with `<Suspense>` and `<ErrorBoundary>` components
- You want guaranteed non-undefined data types
- The query always needs to run (not conditional)

- **Use `useLiveQuery`** when:
- You need conditional/disabled queries
- You prefer handling loading/error states within your component
- You want to show loading states inline without Suspense
- You need access to `status` and `isLoading` flags
- **You're using a router with loaders** (React Router, TanStack Router, etc.) - preload in the loader and use `useLiveQuery` in the component

```tsx
// useLiveQuery - handle states in component
function UserList() {
const { data, status, isLoading } = useLiveQuery((q) =>
q.from({ user: usersCollection })
)

if (isLoading) return <div>Loading...</div>
if (status === 'error') return <div>Error loading users</div>

return <ul>{data?.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}

// useLiveSuspenseQuery - handle states with Suspense/ErrorBoundary
function UserList() {
const { data } = useLiveSuspenseQuery((q) =>
q.from({ user: usersCollection })
)

return <ul>{data.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}

// useLiveQuery with router loader - recommended pattern
// In your route configuration:
const route = {
path: '/users',
loader: async () => {
// Preload the collection in the loader
await usersCollection.preload()
return null
},
component: UserList,
}

// In your component:
function UserList() {
// Collection is already loaded, so data is immediately available
const { data } = useLiveQuery((q) =>
q.from({ user: usersCollection })
)

return <ul>{data?.map(user => <li key={user.id}>{user.name}</li>)}</ul>
}
```

### Conditional Queries

In React, you can conditionally disable a query by returning `undefined` or `null` from the `useLiveQuery` callback. When disabled, the hook returns a special state indicating the query is not active.
Expand Down
28 changes: 28 additions & 0 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,34 @@ const Todos = () => {
}
```

#### `useLiveSuspenseQuery` hook

For React Suspense support, use `useLiveSuspenseQuery`. This hook suspends rendering during initial data load and guarantees that `data` is always defined:

```tsx
import { useLiveSuspenseQuery } from '@tanstack/react-db'
import { Suspense } from 'react'

const Todos = () => {
// data is always defined - no need for optional chaining
const { data: todos } = useLiveSuspenseQuery((q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.completed, false))
)

return <List items={ todos } />
}

const App = () => (
<Suspense fallback={<div>Loading...</div>}>
<Todos />
</Suspense>
)
```

See the [React Suspense section in Live Queries](../guides/live-queries#using-with-react-suspense) for detailed usage patterns and when to use `useLiveSuspenseQuery` vs `useLiveQuery`.

#### `queryBuilder`

You can also build queries directly (outside of the component lifecycle) using the underlying `queryBuilder` API:
Expand Down
1 change: 1 addition & 0 deletions packages/react-db/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Re-export all public APIs
export * from "./useLiveQuery"
export * from "./useLiveSuspenseQuery"
export * from "./useLiveInfiniteQuery"

// Re-export everything from @tanstack/db
Expand Down
Loading
Loading