Skip to content
Closed
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
48 changes: 48 additions & 0 deletions .changeset/tanstack-query-expose-query-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@tanstack/query-db-collection": minor
---

**BREAKING**: Refactor query state utils from functions to getters

This change refactors the query state utility properties from function calls to getters, aligning with TanStack Query's API patterns and providing a more intuitive developer experience.

**Breaking Changes:**

- `collection.utils.lastError()` → `collection.utils.lastError`
- `collection.utils.isError()` → `collection.utils.isError`
- `collection.utils.errorCount()` → `collection.utils.errorCount`
- `collection.utils.isFetching()` → `collection.utils.isFetching`
- `collection.utils.isRefetching()` → `collection.utils.isRefetching`
- `collection.utils.isLoading()` → `collection.utils.isLoading`
- `collection.utils.dataUpdatedAt()` → `collection.utils.dataUpdatedAt`
- `collection.utils.fetchStatus()` → `collection.utils.fetchStatus`

**New Features:**
Exposes TanStack Query's QueryObserver state through new utility getters:

- `isFetching` - Whether the query is currently fetching (initial or background)
- `isRefetching` - Whether the query is refetching in the background
- `isLoading` - Whether the query is loading for the first time
- `dataUpdatedAt` - Timestamp of last successful data update
- `fetchStatus` - Current fetch status ('fetching' | 'paused' | 'idle')

This allows users to:

- Show loading indicators during background refetches
- Implement "Last updated X minutes ago" UI patterns
- Understand sync behavior beyond just error states

**Migration Guide:**
Remove parentheses from all utility property access. Properties are now accessed directly instead of being called as functions:

```typescript
// Before
if (collection.utils.isFetching()) {
console.log("Syncing...", collection.utils.dataUpdatedAt())
}

// After
if (collection.utils.isFetching) {
console.log("Syncing...", collection.utils.dataUpdatedAt)
}
```
145 changes: 121 additions & 24 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export interface QueryCollectionUtils<
TKey extends string | number = string | number,
TInsertInput extends object = TItem,
TError = unknown,
> extends UtilsRecord {
> {
/** Manually trigger a refetch of the query */
refetch: RefetchFn
/** Insert one or more items directly into the synced data store without triggering a query refetch or optimistic update */
Expand All @@ -164,21 +164,48 @@ export interface QueryCollectionUtils<
writeUpsert: (data: Partial<TItem> | Array<Partial<TItem>>) => void
/** Execute multiple write operations as a single atomic batch to the synced data store */
writeBatch: (callback: () => void) => void
/** Get the last error encountered by the query (if any); reset on success */
lastError: () => TError | undefined
/** Check if the collection is in an error state */
isError: () => boolean
/** The last error encountered by the query (if any); reset on success */
readonly lastError: TError | undefined
/** Whether the collection is in an error state */
readonly isError: boolean
/**
* Get the number of consecutive sync failures.
* The number of consecutive sync failures.
* Incremented only when query fails completely (not per retry attempt); reset on success.
*/
errorCount: () => number
readonly errorCount: number
/**
* Clear the error state and trigger a refetch of the query
* @returns Promise that resolves when the refetch completes successfully
* @throws Error if the refetch fails
*/
clearError: () => Promise<void>
/**
* Whether the query is currently fetching data (including background refetches).
* True during both initial fetches and background refetches.
*/
readonly isFetching: boolean
/**
* Whether the query is currently refetching data in the background.
* True only during background refetches (not initial fetch).
*/
readonly isRefetching: boolean
/**
* Whether the query is loading for the first time (no data yet).
* True only during the initial fetch before any data is available.
*/
readonly isLoading: boolean
/**
* The timestamp (in milliseconds since epoch) when the data was last successfully updated.
* Returns 0 if the query has never successfully fetched data.
*/
readonly dataUpdatedAt: number
/**
* The current fetch status of the query.
* - 'fetching': Query is currently fetching
* - 'paused': Query is paused (e.g., network offline)
* - 'idle': Query is not fetching
*/
readonly fetchStatus: `fetching` | `paused` | `idle`
}

/**
Expand Down Expand Up @@ -286,7 +313,7 @@ export function queryCollectionOptions<
schema: T
select: (data: TQueryData) => Array<InferSchemaInput<T>>
}
): CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
): CollectionConfig<InferSchemaOutput<T>, TKey, T, UtilsRecord> & {
schema: T
utils: QueryCollectionUtils<
InferSchemaOutput<T>,
Expand Down Expand Up @@ -319,7 +346,7 @@ export function queryCollectionOptions<
schema?: never // prohibit schema
select: (data: TQueryData) => Array<T>
}
): CollectionConfig<T, TKey> & {
): CollectionConfig<T, TKey, never, UtilsRecord> & {
schema?: never // no schema in the result
utils: QueryCollectionUtils<T, TKey, T, TError>
}
Expand All @@ -343,7 +370,7 @@ export function queryCollectionOptions<
> & {
schema: T
}
): CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
): CollectionConfig<InferSchemaOutput<T>, TKey, T, UtilsRecord> & {
schema: T
utils: QueryCollectionUtils<
InferSchemaOutput<T>,
Expand All @@ -369,14 +396,19 @@ export function queryCollectionOptions<
> & {
schema?: never // prohibit schema
}
): CollectionConfig<T, TKey> & {
): CollectionConfig<T, TKey, never, UtilsRecord> & {
schema?: never // no schema in the result
utils: QueryCollectionUtils<T, TKey, T, TError>
}

export function queryCollectionOptions(
config: QueryCollectionConfig<Record<string, unknown>>
): CollectionConfig & {
): CollectionConfig<
Record<string, unknown>,
string | number,
never,
UtilsRecord
> & {
utils: QueryCollectionUtils
} {
const {
Expand Down Expand Up @@ -427,6 +459,15 @@ export function queryCollectionOptions(
/** Reference to the QueryObserver for imperative refetch */
let queryObserver: QueryObserver<Array<any>, any, Array<any>, Array<any>, any>

/** Query state tracking from QueryObserver */
const queryState = {
isFetching: false,
isRefetching: false,
isLoading: false,
dataUpdatedAt: 0,
fetchStatus: `idle` as `fetching` | `paused` | `idle`,
}

const internalSync: SyncConfig<any>[`sync`] = (params) => {
const { begin, write, commit, markReady, collection } = params

Expand Down Expand Up @@ -466,6 +507,13 @@ export function queryCollectionOptions(

type UpdateHandler = Parameters<typeof localObserver.subscribe>[0]
const handleQueryResult: UpdateHandler = (result) => {
// Update query state from QueryObserver result
queryState.isFetching = result.isFetching
queryState.isRefetching = result.isRefetching
queryState.isLoading = result.isLoading
queryState.dataUpdatedAt = result.dataUpdatedAt
queryState.fetchStatus = result.fetchStatus

if (result.isSuccess) {
// Clear error state
lastError = undefined
Expand Down Expand Up @@ -717,18 +765,67 @@ export function queryCollectionOptions(
onInsert: wrappedOnInsert,
onUpdate: wrappedOnUpdate,
onDelete: wrappedOnDelete,
utils: {
refetch,
...writeUtils,
lastError: () => lastError,
isError: () => !!lastError,
errorCount: () => errorCount,
clearError: async () => {
lastError = undefined
errorCount = 0
lastErrorUpdatedAt = 0
await refetch({ throwOnError: true })
utils: Object.defineProperties(
{
refetch,
...writeUtils,
clearError: async () => {
lastError = undefined
errorCount = 0
lastErrorUpdatedAt = 0
await refetch({ throwOnError: true })
},
},
},
{
lastError: {
get() {
return lastError
},
enumerable: true,
},
isError: {
get() {
return !!lastError
},
enumerable: true,
},
errorCount: {
get() {
return errorCount
},
enumerable: true,
},
isFetching: {
get() {
return queryState.isFetching
},
enumerable: true,
},
isRefetching: {
get() {
return queryState.isRefetching
},
enumerable: true,
},
isLoading: {
get() {
return queryState.isLoading
},
enumerable: true,
},
dataUpdatedAt: {
get() {
return queryState.dataUpdatedAt
},
enumerable: true,
},
fetchStatus: {
get() {
return queryState.fetchStatus
},
enumerable: true,
},
}
) as any,
}
}
Loading
Loading