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
5 changes: 5 additions & 0 deletions .changeset/clarify-query-select-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-db-collection': patch
---

Clarify that `select` extracts rows for DB materialization while preserving the wrapped TanStack Query cache response.
41 changes: 40 additions & 1 deletion docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The `queryCollectionOptions` function accepts the following options:

### Query Options

- `select`: Function that lets extract array items when they're wrapped with metadata
- `select`: Function that extracts the row array TanStack DB materializes from a wrapped Query response
- `enabled`: Whether the query should automatically run (default: `true`)
- `refetchInterval`: Refetch interval in milliseconds (default: 0 — set an interval to enable polling refetching)
- `retry`: Retry configuration for failed queries
Expand Down Expand Up @@ -96,6 +96,45 @@ const todosCollection = createCollection(

If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`.

### Selecting Rows from Wrapped Responses

Many APIs return rows inside a response envelope that also contains metadata such as pagination cursors, totals, or request information. Use `select` to extract the row array that TanStack DB should materialize:

```typescript
interface TodosResponse {
items: Array<{ id: string; title: string }>
nextCursor?: string
total: number
}

const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (): Promise<TodosResponse> => {
const response = await fetch("/api/todos")
return response.json()
},
select: (response) => response.items,
queryClient,
getKey: (item) => item.id,
}),
)
```

`select` is a query-db-collection row extraction hook. It tells TanStack DB which rows to materialize while the TanStack Query cache keeps the original query response shape. In the example above, `queryClient.getQueryData(["todos"])` still returns the full `TodosResponse`, including `nextCursor` and `total`.

This differs from TanStack Query's observer-level `select`: query-db-collection uses this option to bridge Query's response object into DB's normalized row store.

Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` make a best-effort attempt to update the matching row array inside wrapped Query cache entries while preserving wrapper metadata.

This works automatically for simple wrappers such as:

- `{ data: [...] }`
- `{ items: [...] }`
- `{ results: [...] }`

Derived projections, such as `select: (response) => response.edges.map((edge) => edge.node)`, are read-side row extraction only. query-db-collection cannot generally reconstruct the original response envelope from updated rows. Refetch or invalidate the query if the wrapped cache must exactly reflect direct writes for a derived projection.

### Collection Options

- `id`: Unique identifier for the collection
Expand Down
5 changes: 4 additions & 1 deletion packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ export interface QueryCollectionConfig<
) => Promise<Array<any>> | Array<any>
? (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>> | Array<T>
: TQueryFn
/* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */
/**
* Extracts the row array TanStack DB materializes from the Query response.
* The Query cache keeps the original response shape.
*/
select?: (data: TQueryData) => Array<T>
/** The TanStack Query client instance */
queryClient: QueryClient
Expand Down
40 changes: 40 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,46 @@ describe(`QueryCollection`, () => {
expect(initialCache).toEqual(initialMetaData)
})

it(`materializes selected rows while preserving wrapped Query cache response`, async () => {
const queryKey = [`select-row-extraction-test`]
const wrappedResponse = {
items: initialMetaData.data,
meta: { page: 1, total: initialMetaData.data.length },
}
const expectedCacheResponse = {
items: initialMetaData.data.map((item) => ({ ...item })),
meta: { page: 1, total: initialMetaData.data.length },
}

const queryFn = vi.fn().mockResolvedValue(wrappedResponse)
const select = vi.fn((data: typeof wrappedResponse) => data.items)

const options = queryCollectionOptions({
id: `select-row-extraction-test`,
queryClient,
queryKey,
queryFn,
select,
getKey,
startSync: true,
})
const collection = createCollection(options)

await vi.waitFor(() => {
expect(queryFn).toHaveBeenCalledTimes(1)
expect(select).toHaveBeenCalledTimes(1)
expect(collection.size).toBe(wrappedResponse.items.length)
})

expect(stripVirtualProps(collection.get(`1`))).toEqual(
wrappedResponse.items[0],
)
expect(stripVirtualProps(collection.get(`2`))).toEqual(
wrappedResponse.items[1],
)
expect(queryClient.getQueryData(queryKey)).toEqual(expectedCacheResponse)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it(`should not throw error when using writeInsert with select option`, async () => {
const queryKey = [`select-writeInsert-test`]
const consoleErrorSpy = vi
Expand Down
Loading