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`, () => {