diff --git a/.changeset/add-querydata-property.md b/.changeset/add-querydata-property.md new file mode 100644 index 000000000..819cc4f18 --- /dev/null +++ b/.changeset/add-querydata-property.md @@ -0,0 +1,52 @@ +--- +"@tanstack/query-db-collection": patch +--- + +Add `queryData` property to `collection.utils` for accessing full query response including metadata. This resolves the common use case of needing pagination info (total counts, page numbers, etc.) alongside the data array when using the `select` option. + +Previously, when using `select` to extract an array from a wrapped API response, metadata was only accessible via `queryClient.getQueryData()` which was not reactive and required exposing the queryClient. Users resorted to duplicating metadata into every item as a workaround. + +**Example:** + +```ts +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async () => { + const response = await api.getContacts() + // API returns: { data: Contact[], pagination: { total: number } } + return response.json() + }, + select: (response) => response.data, // Extract array for collection + queryClient, + getKey: (contact) => contact.id, + }) +) + +// Access the full response including metadata +const totalCount = contactsCollection.utils.queryData?.pagination?.total + +// Perfect for TanStack Table pagination +function ContactsTable() { + const contacts = useLiveQuery(contactsCollection) + const totalRowCount = contactsCollection.utils.queryData?.total ?? 0 + + const table = useReactTable({ + data: contacts, + columns, + rowCount: totalRowCount, + }) + + return +} +``` + +**Benefits:** + +- Type-safe metadata access (TypeScript infers type from `queryFn` return) +- Reactive updates when query refetches +- Works seamlessly with existing `select` function +- No need to duplicate metadata into items +- Cleaner API than accessing `queryClient` directly + +The property is `undefined` before the first successful fetch and updates automatically on refetches. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 4f43082d7..4ead25cb4 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -77,6 +77,166 @@ The `queryCollectionOptions` function accepts the following options: - `onUpdate`: Handler called before update operations - `onDelete`: Handler called before delete operations +## Working with API Responses + +Many APIs return data wrapped with metadata like pagination info, total counts, or other contextual information. Query Collection provides powerful tools to handle these scenarios. + +### The `select` Option + +When your API returns wrapped responses (data with metadata), use the `select` function to extract the array: + +```typescript +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async () => { + const response = await fetch('/api/contacts') + // API returns: { data: Contact[], pagination: { total: number } } + return response.json() + }, + select: (response) => response.data, // Extract the array + queryClient, + getKey: (contact) => contact.id, + }) +) +``` + +### Accessing Metadata with `queryData` + +While `select` extracts the array for the collection, you often need access to the metadata (like pagination info). Use `collection.utils.queryData` to access the full response: + +```typescript +// The full API response is available via utils.queryData +const totalContacts = contactsCollection.utils.queryData?.pagination?.total +const currentPage = contactsCollection.utils.queryData?.pagination?.page + +// Use in your components +function ContactsTable() { + const contacts = useLiveQuery(contactsCollection) + const totalCount = contactsCollection.utils.queryData?.pagination?.total ?? 0 + + return ( +
+

Showing {contacts.length} of {totalCount} contacts

+ + + ) +} +``` + +### Type-Safe Metadata Access + +TypeScript automatically infers the type of `queryData` from your `queryFn` return type: + +```typescript +interface ContactsResponse { + data: Contact[] + pagination: { + total: number + page: number + perPage: number + } + metadata: { + lastSync: string + } +} + +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async (): Promise => { + const response = await fetch('/api/contacts') + return response.json() + }, + select: (response) => response.data, + queryClient, + getKey: (contact) => contact.id, + }) +) + +// TypeScript knows the structure of queryData +const total = contactsCollection.utils.queryData?.pagination.total // ✅ Type-safe +const lastSync = contactsCollection.utils.queryData?.metadata.lastSync // ✅ Type-safe +``` + +### Real-World Example: TanStack Table with Pagination + +A common use case is integrating with TanStack Table for server-side pagination: + +```typescript +const contactsCollection = createCollection( + queryCollectionOptions({ + queryKey: ['contacts'], + queryFn: async (ctx) => { + const { limit, offset, sorts } = parseLoadSubsetOptions( + ctx.meta?.loadSubsetOptions + ) + + const response = await fetch('/api/contacts', { + method: 'POST', + body: JSON.stringify({ limit, offset, sorts }), + }) + + return response.json() // { data: Contact[], total: number } + }, + select: (response) => response.data, + queryClient, + getKey: (contact) => contact.id, + }) +) + +function ContactsTable() { + const contacts = useLiveQuery(contactsCollection) + const totalRowCount = contactsCollection.utils.queryData?.total ?? 0 + + // Use with TanStack Table + const table = useReactTable({ + data: contacts, + columns, + rowCount: totalRowCount, + // ... other options + }) + + return +} +``` + +### Without `select`: Direct Array Returns + +If your API returns a plain array, you don't need `select`. In this case, `queryData` will contain the array itself: + +```typescript +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ['todos'], + queryFn: async () => { + const response = await fetch('/api/todos') + return response.json() // Returns Todo[] directly + }, + queryClient, + getKey: (todo) => todo.id, + }) +) + +// queryData is the array +const todos = todosCollection.utils.queryData // Todo[] | undefined +``` + +### Reactive Updates + +The `queryData` property is reactive and updates automatically when: +- The query refetches +- Data is invalidated and refetched +- Manual refetch is triggered + +```typescript +// Trigger a refetch +await contactsCollection.utils.refetch() + +// queryData is automatically updated with new response +const newTotal = contactsCollection.utils.queryData?.pagination?.total +``` + ## Persistence Handlers You can define handlers that are called when mutations occur. These handlers can persist changes to your backend and control whether the query should refetch after the operation: @@ -135,13 +295,35 @@ This is useful when: ## Utility Methods -The collection provides these utility methods via `collection.utils`: +The collection provides utilities via `collection.utils` for managing the collection and accessing query state: + +### Methods - `refetch(opts?)`: Manually trigger a refetch of the query - `opts.throwOnError`: Whether to throw an error if the refetch fails (default: `false`) - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) - Returns `QueryObserverResult` for inspecting the result +- `clearError()`: Clear the error state and trigger a refetch + +- `writeInsert(data)`: Insert items directly to synced data (see [Direct Writes](#direct-writes)) +- `writeUpdate(data)`: Update items directly in synced data +- `writeDelete(keys)`: Delete items directly from synced data +- `writeUpsert(data)`: Insert or update items directly in synced data +- `writeBatch(callback)`: Perform multiple write operations atomically + +### Properties + +- `queryData`: The full response from `queryFn`, including metadata (see [Working with API Responses](#working-with-api-responses)) +- `lastError`: The last error encountered (if any) +- `isError`: Whether the collection is in an error state +- `errorCount`: Number of consecutive sync failures +- `isFetching`: Whether the query is currently fetching +- `isRefetching`: Whether the query is refetching in the background +- `isLoading`: Whether the query is loading for the first time +- `dataUpdatedAt`: Timestamp of the last successful data update +- `fetchStatus`: Current fetch status (`'fetching'`, `'paused'`, or `'idle'`) + ## Direct Writes Direct writes are intended for scenarios where the normal query/mutation flow doesn't fit your needs. They allow you to write directly to the synced data store, bypassing the optimistic update system and query refetch mechanism. diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index ee915aabf..7312182b0 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -156,6 +156,7 @@ export interface QueryCollectionUtils< TKey extends string | number = string | number, TInsertInput extends object = TItem, TError = unknown, + TQueryData = any, > extends UtilsRecord { /** Manually trigger a refetch of the query */ refetch: RefetchFn @@ -191,6 +192,25 @@ export interface QueryCollectionUtils< /** Get current fetch status */ fetchStatus: `fetching` | `paused` | `idle` + /** + * The full query response data from queryFn, including any metadata. + * When using the select option, this contains the raw response before extraction. + * Useful for accessing pagination info, total counts, or other API metadata. + * + * @example + * // Without select - queryData is the array + * queryFn: async () => fetchContacts(), // returns Contact[] + * // queryData will be Contact[] + * + * @example + * // With select - queryData is the full response + * queryFn: async () => fetchContacts(), // returns { data: Contact[], total: number } + * select: (response) => response.data, + * // queryData will be { data: Contact[], total: number } + * const total = collection.utils.queryData?.total + */ + queryData: TQueryData | undefined + /** * Clear the error state and trigger a refetch of the query * @returns Promise that resolves when the refetch completes successfully @@ -206,6 +226,7 @@ interface QueryCollectionState { lastError: any errorCount: number lastErrorUpdatedAt: number + queryData: any observers: Map< string, QueryObserver, any, Array, Array, any> @@ -302,6 +323,10 @@ class QueryCollectionUtilsImpl { (observer) => observer.getCurrentResult().fetchStatus ) } + + public get queryData() { + return this.state.queryData + } } /** @@ -574,6 +599,7 @@ export function queryCollectionOptions( lastError: undefined as any, errorCount: 0, lastErrorUpdatedAt: 0, + queryData: undefined as any, observers: new Map< string, QueryObserver, any, Array, Array, any> @@ -731,6 +757,7 @@ export function queryCollectionOptions( state.errorCount = 0 const rawData = result.data + state.queryData = rawData const newItemsArray = select ? select(rawData) : rawData if ( diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 2f218d73a..e6bcbe7c2 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -568,6 +568,138 @@ describe(`QueryCollection`, () => { ) as MetaDataType expect(initialCache).toEqual(initialMetaData) }) + + it(`queryData is accessible via utils when using select`, async () => { + const queryKey = [`queryData-select-test`] + + const queryFn = vi.fn().mockResolvedValue(initialMetaData) + const select = vi.fn().mockReturnValue(initialMetaData.data) + + const options = queryCollectionOptions({ + id: `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(2) + }) + + // Verify that queryData contains the full response + expect(collection.utils.queryData).toEqual(initialMetaData) + expect(collection.utils.queryData?.metaDataOne).toBe(`example metadata`) + expect(collection.utils.queryData?.metaDataTwo).toBe(`example metadata`) + }) + + it(`queryData contains array when not using select`, async () => { + const queryKey = [`queryData-no-select-test`] + const items = [ + { id: `1`, name: `Item 1` }, + { id: `2`, name: `Item 2` }, + ] + + const queryFn = vi.fn().mockResolvedValue(items) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + }) + const collection = createCollection(options) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(2) + }) + + // Verify that queryData contains the array directly + expect(collection.utils.queryData).toEqual(items) + }) + + it(`queryData updates reactively when query refetches`, async () => { + const queryKey = [`queryData-refetch-test`] + + const initialData: MetaDataType = { + metaDataOne: `initial`, + metaDataTwo: `metadata`, + data: [{ id: `1`, name: `Initial Item` }], + } + + const updatedData: MetaDataType = { + metaDataOne: `updated`, + metaDataTwo: `metadata`, + data: [ + { id: `1`, name: `Updated Item` }, + { id: `2`, name: `New Item` }, + ], + } + + const queryFn = vi + .fn() + .mockResolvedValueOnce(initialData) + .mockResolvedValueOnce(updatedData) + const select = vi.fn((data: MetaDataType) => data.data) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + select, + getKey, + startSync: true, + }) + const collection = createCollection(options) + + // Wait for initial data + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + // Verify initial queryData + expect(collection.utils.queryData).toEqual(initialData) + expect(collection.utils.queryData?.metaDataOne).toBe(`initial`) + + // Trigger refetch + await collection.utils.refetch() + + // Wait for updated data + await vi.waitFor(() => { + expect(collection.size).toBe(2) + }) + + // Verify queryData has been updated + expect(collection.utils.queryData).toEqual(updatedData) + expect(collection.utils.queryData?.metaDataOne).toBe(`updated`) + }) + + it(`queryData is undefined initially before first fetch`, () => { + const queryKey = [`queryData-initial-test`] + const queryFn = vi.fn().mockResolvedValue(initialMetaData.data) + + const options = queryCollectionOptions({ + id: `test`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: false, // Don't start sync automatically + }) + const collection = createCollection(options) + + // Before sync starts, queryData should be undefined + expect(collection.utils.queryData).toBeUndefined() + }) }) describe(`Direct persistence handlers`, () => { it(`should pass through direct persistence handlers to collection options`, () => {