Skip to content
Open
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
52 changes: 52 additions & 0 deletions .changeset/add-querydata-property.md
Original file line number Diff line number Diff line change
@@ -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 <TableComponent table={table} />
}
```

**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.
184 changes: 183 additions & 1 deletion docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<p>Showing {contacts.length} of {totalCount} contacts</p>
<Table data={contacts} />
</div>
)
}
```

### 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<ContactsResponse> => {
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 <TableComponent table={table} />
}
```

### 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:
Expand Down Expand Up @@ -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'`)
Comment on lines +321 to +325
Copy link
Collaborator

Choose a reason for hiding this comment

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

With us having 1:n collection to query the isXX return true when any of the queries are in that state, dataUpdatedAt is the most recent query update time, and fetchStatus is an array of statuses.


## 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.
Expand Down
27 changes: 27 additions & 0 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export interface QueryCollectionUtils<
TKey extends string | number = string | number,
TInsertInput extends object = TItem,
TError = unknown,
TQueryData = any,
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be unknown rather than any?

> extends UtilsRecord {
/** Manually trigger a refetch of the query */
refetch: RefetchFn
Expand Down Expand Up @@ -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
Expand All @@ -206,6 +226,7 @@ interface QueryCollectionState {
lastError: any
errorCount: number
lastErrorUpdatedAt: number
queryData: any
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be TQueryData instead of any?

observers: Map<
string,
QueryObserver<Array<any>, any, Array<any>, Array<any>, any>
Expand Down Expand Up @@ -302,6 +323,10 @@ class QueryCollectionUtilsImpl {
(observer) => observer.getCurrentResult().fetchStatus
)
}

public get queryData() {
return this.state.queryData
}
}

/**
Expand Down Expand Up @@ -574,6 +599,7 @@ export function queryCollectionOptions(
lastError: undefined as any,
errorCount: 0,
lastErrorUpdatedAt: 0,
queryData: undefined as any,
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really need to type it as any?

observers: new Map<
string,
QueryObserver<Array<any>, any, Array<any>, Array<any>, any>
Expand Down Expand Up @@ -731,6 +757,7 @@ export function queryCollectionOptions(
state.errorCount = 0

const rawData = result.data
state.queryData = rawData
const newItemsArray = select ? select(rawData) : rawData

if (
Expand Down
Loading
Loading