Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 51 additions & 0 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,57 @@ Some TanStack Query fields are owned or reinterpreted by Query Collection and ar

`placeholderData` is intentionally unsupported. TanStack Query treats placeholder data as observer-local presentation state rather than cached Query data. Materializing it would expose temporary UI data as collection-wide normalized rows. Render placeholders in the consuming UI instead.

### Request Cancellation with `QueryFunctionContext.signal`

TanStack Query passes an `AbortSignal` to `queryFn` through the query function
context. Forward `ctx.signal` to `fetch` or another abortable client to make the
request cancellable:

```typescript
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (ctx) => {
const response = await fetch("/api/todos", {
signal: ctx.signal,
})

if (!response.ok) {
throw new Error("Failed to fetch todos")
}

return response.json() as Promise<Array<Todo>>
},
queryClient,
getKey: (todo) => todo.id,
}),
)
```

Explicit collection cleanup cancels each exact Query key the collection is
currently tracking before removing it from the Query cache:

```typescript
await todosCollection.cleanup()
```

The underlying request is aborted only when its client consumes `ctx.signal`.
A client that ignores the signal may continue its request even though the
collection has been cleaned up.

An unloaded on-demand subset is no longer tracked. A later explicit collection
cleanup does not revisit its Query key.

Query cache entries are shared within a `QueryClient`. Explicit cleanup can
affect other consumers using the same exact Query keys.

On-demand subset unloading does not explicitly call
`queryClient.cancelQueries()`. It removes the subset's Query observer. If this
was the final observer and the query function consumed `ctx.signal`, TanStack
Query aborts the request. If the signal was ignored, or another observer still
uses the same exact Query key, the request may finish and remain cached until
`gcTime`.
Comment on lines +288 to +293

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching query-collection.md:\n'
fd -a 'query-collection\.md$' . || true

file="$(fd 'query-collection\.md$' . | head -n1)"
if [ -n "${file:-}" ]; then
  printf '\nLine count:\n'
  wc -l "$file"
  printf '\nRelevant section:\n'
  sed -n '270,310p' "$file" | cat -n
fi

printf '\nNearby query cancellation docs mentions:\n'
rg -n "gcTime|cancelQueries|Query observer|observer|remaining observer|active" docs || true

# Inspect package metadata for TanStack Query version/doc source if relevant
printf '\nLock/package TanStack Query references:\n'
rg -n "`@tanstack/react-query`|`@tanstack/query-core`|tanstack/query|query-cancellation|gcTime" package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null || true

Repository: TanStack/db

Length of output: 39251


🌐 Web query:

TanStack Query 5 observer lifecycle gcTime when query becomes inactive documentation

💡 Result:

In TanStack Query v5, a query becomes inactive when it no longer has any active observers (e.g., all components using a hook like useQuery have unmounted) [1][2][3]. Once a query transitions to the inactive state, TanStack Query begins a garbage collection countdown based on the query's gcTime configuration [2][3]. Key aspects of this lifecycle include: 1. Inactive State: A query is considered inactive when it is present in the cache but is not currently being used by any observers [1][3]. Data persists in the cache in this state, allowing for instant reuse if the query becomes active again before the garbage collection timeout expires [2][3]. 2. gcTime (Garbage Collection Time): Formerly known as cacheTime in earlier versions, gcTime defines the duration (in milliseconds) that inactive query data remains in memory before being garbage collected and deleted [4][5][6]. 3. Default Behavior: The default gcTime is 5 minutes (5 * 60 * 1000 milliseconds) [1][6]. During Server-Side Rendering (SSR), this defaults to Infinity to prevent premature data removal [6]. 4. Cleanup Process: If no new observers mount for that query key before the gcTime expires, the query is removed from the cache entirely [2][6]. If a new observer mounts while the query is still inactive (i.e., before the timer expires), the garbage collection process is cancelled, and the query returns to an active state [2]. If multiple instances of a query exist with different gcTime settings, the library uses the longest duration to ensure data availability [6]. You can set gcTime to Infinity to disable garbage collection for a specific query [6].

Citations:


Separate active-observer behavior from gcTime.

A case with another observer on the same Query key is different from ignored-signal behavior. With remaining observers, the query stays active until the last observer is removed; with an ignored signal, the unobserved request can finish and only later reach gcTime while the data is inactive.

Suggested wording
-On-demand subset unloading does not explicitly call
-`queryClient.cancelQueries()`. It removes the subset's Query observer. If
-this was the final observer and the query function consumed `ctx.signal`,
-TanStack Query aborts the request. If the signal was ignored, or another
-observer still uses the same exact Query key, the request may finish and remain
-cached until `gcTime`.
+On-demand subset unloading does not explicitly call
+`queryClient.cancelQueries()`. It removes the subset's Query observer. If
+this was the final observer and the query function consumed `ctx.signal`,
+TanStack Query aborts the request. If the signal was ignored, the request may
+finish and remain cached until the query becomes inactive and reaches `gcTime`.
+If another observer still uses the same exact Query key, the query remains
+active until that observer is also removed.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
On-demand subset unloading does not explicitly call
`queryClient.cancelQueries()`. It removes the subset's Query observer. If this
was the final observer and the query function consumed `ctx.signal`, TanStack
Query aborts the request. If the signal was ignored, or another observer still
uses the same exact Query key, the request may finish and remain cached until
`gcTime`.
On-demand subset unloading does not explicitly call
`queryClient.cancelQueries()`. It removes the subset's Query observer. If this
was the final observer and the query function consumed `ctx.signal`, TanStack
Query aborts the request. If the signal was ignored, the request may finish
and remain cached until the query becomes inactive and reaches `gcTime`. If
another observer still uses the same exact Query key, the query remains
active until that observer is also removed.
🧰 Tools
🪛 LanguageTool

[style] ~292-~292: This phrase is redundant. Consider writing “same”.
Context: ...red, or another observer still uses the same exact Query key, the request may finish and r...

(SAME_EXACT)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/collections/query-collection.md` around lines 288 - 293, Update the
on-demand subset unloading documentation to distinguish remaining-observer
behavior from ignored-signal behavior: explain that another observer keeps the
query active until the final observer is removed, while an unobserved request
whose signal is ignored may complete and remain inactive until gcTime.

Source: MCP tools


### Using with `queryOptions(...)`

If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tanstack/react-query`), you can spread compatible top-level options into `queryCollectionOptions`. Note that `queryFn` must be explicitly provided since query collections require it both in types and at runtime, and Query Collection's `select` option is for row extraction rather than TanStack Query observer-level selection:
Expand Down
14 changes: 9 additions & 5 deletions packages/db/skills/db-core/collection-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,22 @@ const todoSchema = z.object({
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
queryFn: async (ctx) => {
const res = await fetch('/api/todos', { signal: ctx.signal })
return res.json()
},
queryClient,
getKey: (item) => item.id,
schema: todoSchema,
onInsert: async ({ transaction }) => {
await api.todos.create(transaction.mutations[0].modified)
await todoCollection.utils.refetch()
},
onUpdate: async ({ transaction }) => {
const mut = transaction.mutations[0]
await api.todos.update(mut.key, mut.changes)
await todoCollection.utils.refetch()
},
onDelete: async ({ transaction }) => {
await api.todos.delete(transaction.mutations[0].key)
await todoCollection.utils.refetch()
},
}),
)
Expand Down Expand Up @@ -105,6 +102,13 @@ queryCollectionOptions({
| `on-demand` | Search, catalogs, large tables | >50k rows |
| `progressive` | Collaborative apps needing instant first paint (Electric only) | Any |

Calling `collection.preload()` on an on-demand collection is a no-op. Create
the live query for the required subset and call `liveQuery.preload()` instead.

For Query Collection request cancellation, cleanup boundaries, and shared
`QueryClient` behavior, read
[the Query adapter reference](references/query-adapter.md#request-cancellation-and-cleanup).

## Indexing

Indexing is opt-in. The `autoIndex` option defaults to `"off"`. To enable automatic indexing, set `autoIndex: "eager"` and provide a `defaultIndexType`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const queryClient = new QueryClient()
const collection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: async () => fetch('/api/todos').then((r) => r.json()),
queryFn: async (ctx) =>
fetch('/api/todos', { signal: ctx.signal }).then((r) => r.json()),
queryClient,
getKey: (item) => item.id,
}),
Expand Down Expand Up @@ -50,7 +51,7 @@ const collection = createCollection(
```typescript
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified))
// return nothing or { refetch: true } to trigger refetch
// Query Collection automatically refetches and awaits the result.
// return { refetch: false } to skip refetch
},
onUpdate: async ({ transaction }) => {
Expand Down Expand Up @@ -80,6 +81,33 @@ collection.utils.writeBatch(() => {
})
```

## Request Cancellation and Cleanup

TanStack Query passes an `AbortSignal` through the query function context.
Forward it to `fetch` or another abortable client:

```typescript
queryFn: async (ctx) => {
const response = await fetch('/api/todos', { signal: ctx.signal })
return response.json()
},
```

Explicit `collection.cleanup()` cancels each exact Query key the collection is
currently tracking, then removes it from the Query cache. The underlying client
stops work only when it consumes `ctx.signal`.

An unloaded on-demand subset is no longer tracked, so later collection cleanup
does not revisit its Query key. Unloading does not explicitly call
`queryClient.cancelQueries()`: it removes the subset's Query observer. If that
was the final observer and the query function consumed `ctx.signal`, Query Core
aborts the request. If the signal was ignored, or another observer still uses
the same exact key, the request may finish and remain cached until `gcTime`.

Query cache entries are shared within a `QueryClient`. Explicit cleanup can
cancel or remove entries used by another collection or Query consumer with the
same exact key.

## Predicate Push-Down (syncMode: "on-demand")

Query predicates (where, orderBy, limit, offset) passed to `queryFn` via `ctx.meta.loadSubsetOptions`.
Expand Down Expand Up @@ -151,7 +179,9 @@ const productsCollection = createCollection(
)
}
if (limit) params.set('limit', String(limit))
return fetch(`/api/products?${params}`).then((r) => r.json())
return fetch(`/api/products?${params}`, {
signal: ctx.signal,
}).then((r) => r.json())
},
onInsert: async ({ transaction }) => {
const serverItems = await api.createProducts(
Expand Down Expand Up @@ -213,3 +243,5 @@ When using a function-based `queryKey`, all derived keys must share the base key
- `queryFn` result is treated as **complete state** -- missing items are deleted
- Empty array from `queryFn` deletes all items
- Direct writes update TQ cache but are overridden by subsequent `queryFn` results
- Persistence handlers automatically refetch unless they return `{ refetch: false }`
- On-demand `collection.preload()` is a no-op; preload the live query instead
23 changes: 12 additions & 11 deletions packages/db/skills/db-core/mutations-optimistic/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ a good fit for draft-style flows where local state updates immediately but the
server call waits for Save/Blur; call `tx.rollback()` to discard the optimistic
changes.

### 4. Mutation handler with refetch (QueryCollection pattern)
### 4. Mutation handlers with automatic refetch (QueryCollection pattern)

```ts
const todoCollection = createCollection(
Expand All @@ -229,8 +229,7 @@ const todoCollection = createCollection(
await Promise.all(
transaction.mutations.map((m) => api.todos.create(m.modified)),
)
// IMPORTANT: handler must not resolve until server state is synced back
// QueryCollection auto-refetches after handler completes
// Query Collection refetches after the handler completes and awaits it.
},
onUpdate: async ({ transaction }) => {
await Promise.all(
Expand Down Expand Up @@ -341,26 +340,28 @@ If an item with the same key already exists (synced or optimistic), throws
`DuplicateKeyError`. Always generate a unique key (e.g. `crypto.randomUUID()`)
or check before inserting.

### HIGH: Not awaiting refetch after mutation in query collection handler
### HIGH: Manually refetching inside a Query Collection handler

The optimistic state is held only until the handler resolves. If the handler
returns before server state has synced back, optimistic state is dropped and
users see a flash of missing data.
Query Collection automatically refetches after `onInsert`, `onUpdate`, and
`onDelete` complete, and waits for that refetch before the mutation finishes.
Calling `utils.refetch()` inside the handler sends a redundant request.

```ts
// WRONG -- optimistic state dropped before new server state arrives
// WRONG -- causes one manual and one automatic refetch
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
// missing: await collection.utils.refetch()
await collection.utils.refetch()
}

// CORRECT
// CORRECT -- automatic refetch is awaited after this returns
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
await collection.utils.refetch()
}
```

When the handler writes the confirmed server result with direct-write utilities,
return `{ refetch: false }` to skip the automatic refetch.

---

## Tension: Optimistic Speed vs. Data Consistency
Expand Down
47 changes: 41 additions & 6 deletions packages/db/skills/meta-framework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ name: meta-framework
description: >
Integrating TanStack DB with meta-frameworks (TanStack Start, Next.js,
Remix, Nuxt, SvelteKit). Client-side only: SSR is NOT supported — routes
must disable SSR. Preloading collections in route loaders with
collection.preload(). Pattern: ssr: false + await collection.preload() in
loader. Multiple collection preloading with Promise.all. Framework-specific
loader APIs.
must disable SSR. Preloading eager collections in route loaders with
collection.preload(). On-demand Query Collections require preloading the
live query because source collection preload is a no-op. Multiple collection
preloading with Promise.all. Framework-specific loader APIs.
type: composition
library: db
library_version: '0.6.0'
Expand All @@ -28,7 +28,7 @@ This skill builds on db-core. Read it first for collection setup and query build
TanStack DB collections are **client-side only**. SSR is not implemented. Routes using TanStack DB **must disable SSR**. The setup pattern is:

1. Set `ssr: false` on the route
2. Call `collection.preload()` in the route loader
2. Preload the eager collection, or preload the live query for an on-demand source
3. Use `useLiveQuery` in the component

## TanStack Start
Expand Down Expand Up @@ -73,6 +73,35 @@ function TodoPage() {
}
```

### On-demand Query Collection preload

Calling `preload()` on an on-demand source collection is a no-op. Define the
live query once, preload it in the loader, and pass that same collection to the
framework hook:

```tsx
import { createLiveQueryCollection, eq } from '@tanstack/db'
import { useLiveQuery } from '@tanstack/react-db'

const activeTodos = createLiveQueryCollection((q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.completed, false)),
)

export const Route = createFileRoute('/todos')({
ssr: false,
loader: async () => {
await activeTodos.preload()
return null
},
component: () => {
const { data } = useLiveQuery(activeTodos)
// ...
},
})
```

### Multiple collection preloading

```tsx
Expand Down Expand Up @@ -227,7 +256,9 @@ export const ssr = false

### What preload() does

`collection.preload()` starts the sync process and returns a promise that resolves when the collection reaches "ready" status. This means:
For eager collections, `collection.preload()` starts the sync process and
returns a promise that resolves when the collection reaches "ready" status.
This means:

1. The sync function connects to the backend
2. Initial data is fetched and written to the collection
Expand All @@ -236,6 +267,10 @@ export const ssr = false

Subsequent calls to `preload()` on an already-ready collection return immediately.

For on-demand collections, source `collection.preload()` warns and does
nothing because no subset has been requested. Create the required live query
and await `liveQuery.preload()`.

### Collection module pattern

Define collections in a shared module, import in both loaders and components:
Expand Down
Loading