Skip to content

Local-First Data (@taladb/react/query)

useQuery and useMutation with TanStack Query v5's surface — but the cache is a real TalaDB collection rather than an in-memory store.

Three things follow from that, and they are the whole reason this exists:

  • It survives reload with no rehydration step, because it was never a serialised snapshot.
  • It stays queryable offline. find, $sort, aggregate, and full-text search all run over the data you fetched.
  • Writes never block the UI. A mutation commits locally and drains to your server in the background — no spinner, and the write survives a reload, a crash, or a flight.

Both hooks speak TanStack Query v5. If you already use React Query, read the migration page instead — the differences are short and this page will be mostly familiar.

This is not a cache

The documents land in your collections — the same ones useFind reads. They are durable data, not a disposable projection. Nothing here is ever deleted by age: freshness marks a query stale so it refetches, and that is all it does.


Step 1 — Install

bash
pnpm add taladb @taladb/react
# plus the binding for your platform:
pnpm add @taladb/web            # browser (WASM)
pnpm add @taladb/node           # Node.js
pnpm add @taladb/react-native   # React Native

Everything on this page is imported from the /query subpath, so an app that only uses the local hooks never pulls any of it into the bundle:

ts
import { useQuery, useMutation, useSyncStatus } from '@taladb/react/query'

Step 2 — Describe your collections

Register each collection once, on TalaDBProvider. This is optional for the local hooks and strongly recommended here, because it is what lets useQuery verify the collection it inferred from your query key instead of creating one nobody declared.

tsx
import { z } from 'zod'

const Todo = z.object({
  _id: z.string(),
  title: z.string(),
  done: z.boolean(),
})

const collections = { todos: { schema: Todo } }

The schema is any Standard Schema validator — Zod, Valibot, ArkType. It gives you validation on local writes and a _v shape version on every document.

Step 3 — Wrap your app

QueryProvider nests inside TalaDBProvider, so it inherits the client-only openDB lifecycle.

tsx
import { TalaDBProvider } from '@taladb/react'
import { QueryProvider } from '@taladb/react/query'

root.render(
  <TalaDBProvider name="myapp.db" collections={collections} fallback={<Splash />}>
    <QueryProvider headers={() => ({ Authorization: `Bearer ${getToken()}` })}>
      <App />
    </QueryProvider>
  </TalaDBProvider>,
)

That is the whole setup. Endpoints are not configured here — they live on the hooks that use them.

headers is resolved at send time, not when the write was made, so a write queued while offline and sent an hour later carries a current token rather than the expired one it was made with. That timing is why it belongs on the provider.

There is no QueryClient

TanStack's client owns its cache; here the database owns it. Anything you passed to new QueryClient({ defaultOptions }) goes on <QueryProvider defaultOptions> instead, in the same shape.

Nothing inside this provider is server-rendered

The database opens on the client, so TalaDBProvider renders fallback (default: null) until it is ready — during SSR and on the first client paint. Everything below it is therefore absent from the server-rendered HTML, and there is no dehydrate/HydrationBoundary equivalent to prefetch through.

In a Next.js App Router app this has two consequences:

  • Do not wrap the root layout, or a marketing page whose content has to be in the HTML. Mount the provider around the smallest subtree that actually reads from TalaDB, and give fallback the same shape the server used to render so the layout does not jump.
  • Moving a query off a server-prefetched cache costs you that prefetch. Weigh it per route: an authenticated dashboard rarely cares, a public page ranked by search engines does.

Bundlers and the optional native adapter

taladb picks its storage adapter at runtime, which means the browser build contains an import('@taladb/node') on a branch it never takes. @taladb/node is an optional peer dependency that a web app rightly does not install, and bundlers resolve dynamic specifiers statically — so some setups fail with Module not found: Can't resolve '@taladb/node'.

Since 0.11.1 that specifier carries webpackIgnore and turbopackIgnore comments and the browser and React Native builds stub it outright, so webpack, Turbopack, Vite and Metro all resolve it without configuration. On an older version, alias it to an empty module — for Next.js:

ts
// next.config.ts
turbopack: { resolveAlias: { '@taladb/node': './stubs/empty.js' } }

For everything else a Next.js app needs — where to mount the provider, the CSP directives, the webpack fallback, StrictMode, and the size of the WASM payload — see Next.js setup.

Step 4 — Read

tsx
function TodoList({ status }: { status: string }) {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ['todos', { status }],
    queryFn: ({ signal }) => api.get(`/todos?status=${status}`, { signal }),
    staleTime: 5 * 60_000,
  })

  if (isPending) return <Spinner />
  if (isError) return <p role="alert">{error.message}</p>

  return (
    <ul aria-busy={isFetching}>
      {data.map((todo) => <li key={todo._id}>{todo.title}</li>)}
    </ul>
  )
}
  • data is resolved through the local database, not from the fetch response, so a local write to any document in the set re-renders the query immediately. There is nothing to invalidate — the collection is the state.
  • isPending is true only when nothing is cached at all. A warm key renders instantly and revalidates behind it, reporting isFetching while it does — a reload never shows a spinner over data the device already holds.
  • error never clears data. A failed refresh leaves what you had, and reports isRefetchError rather than isLoadingError.

Documents arrive in the order your server returned them.

One request per key

Components sharing a queryKey share the request. A list, a count badge and a header that all read ['todos'] produce one fetch, not three — the second and third join the first rather than starting their own.

The whole fetch is shared, not just the network call: hydrating the same documents once per component would multiply writes, and on a secondary browser tab every one of those is forwarded to the primary against a bounded buffer.

A request is abandoned only when every component waiting on it has gone, so one unmounting never cancels a fetch the others still need.

Where the documents go

queryKey[0] names the collection — ['todos', …] stores into todos. That name is checked against the collections you registered in step 2, so a typo is an error rather than a new collection nobody declared.

If your keys are shaped differently, fix it once on the provider rather than at every call site:

tsx
<QueryProvider resolveCollection={(key) => key[2] as string}>
  {/* queryKey: ['api', 'v2', 'todos'] → the `todos` collection */}

Or per query, with collection: 'todos'.

A collection is where documents live; a key is what goes stale. They are separate on purpose: one document belongs to many queries, each with its own freshness.

What queryFn may return

data is rebuilt from the collection, so the response has to reduce to documents with an _id — and that _id must be a ULID, which your server's id almost certainly is not. Derive one from whatever natural key the row already has:

ts
import { deriveDocId } from 'taladb'

queryFn: async () => {
  const rows = await api.get('/todos')
  return rows.map((row) => ({ ...row, _id: deriveDocId('todos', String(row.id)) }))
}

The mapping is deterministic, so refetching updates the rows it already stored rather than inserting duplicates. Your original id stays on the document as an ordinary field. A hashed id has no chronological prefix, so reads over a hydrated collection need an explicit $sort — see the ordering caveat.

Three shapes are recognised:

queryFn returnsdata is
Todo[]the live documents, in server order
Todothat live document — undefined if it is deleted locally
anything elsean error naming documents and assemble

An envelope names its own halves:

tsx
useQuery({
  queryKey: ['todos', page],
  queryFn: () => api.get(`/todos?page=${page}`),   // { items, total }
  documents: (raw) => raw.items,
  assemble: (items, raw) => ({ ...raw, items }),
})

data.items stays live — a local edit re-renders it — while data.total is whatever the server said.

Narrowing locally with where

where filters the query's result set in the engine, without another request:

tsx
const key = ['todos']
const all    = useQuery({ queryKey: key, queryFn })
const active = useQuery({ queryKey: key, queryFn, where: { done: false } })
const done   = useQuery({ queryKey: key, queryFn, where: { done: true } })

One fetch, three views. where is deliberately not part of the query key — that is the whole point. It takes the full filter DSL: $in, $gte, $contains, $and, and the rest.

Two things it does not do, both on purpose:

  • It never changes what is stored. Every document the server sent is still in the collection, and still visible to other queries and to useFind.
  • It never changes the query record. Membership stays exactly what the server returned, so a refetch cannot fight the filter — and a locally-excluded document is never confused with one the server deleted.

It composes with everything else: server order is preserved within the narrowed set, an empty narrowing is success with data: [] rather than pending, and under assemble it applies to the documents before they are reassembled, so a server-side total stays the server's.

The reason to reach for it over select is that it runs against an index rather than a JavaScript .filter, and that it reacts to local writes: a document edited out of the predicate leaves the view immediately.

Deletes you cannot see

If a refetch returns fewer documents, they leave that query's result set but are not deleted. From the client, "no longer matches this filter" and "deleted upstream" are indistinguishable — and one of them is not yours to act on.

Step 5 — Write

The write surface is TanStack Query v5's too: mutate(variables) takes the document, and the operation is declared once on the hook — one hook per operation, which is how React Query code is already written.

tsx
function AddTodo() {
  const { mutate, isPending, isError, error } = useMutation<Todo>({
    collection: 'todos',
    url: '/api/v2/todos/:id',
    onSuccess: (todo) => router.push(`/todos/${todo._id}`),
  })

  return (
    <form onSubmit={(e) => { e.preventDefault(); mutate({ title, done: false }) }}>
      <button disabled={isPending}>Add</button>
      {isError && <p role="alert">{error.message}</p>}
    </form>
  )
}

Every useQuery and useFind watching those documents re-renders on the local commit, long before the network hears about it. There is nothing to invalidate.

The three operations

tsx
useMutation<Todo>({ collection: 'todos', url: '/api/v2/todos/:id' })
  .mutate({ title: 'Buy milk' })                    // POST   /api/v2/todos

useMutation<Todo>({ collection: 'todos', url: '/api/v2/todos/:id', operation: 'update' })
  .mutate({ _id, done: true })                      // PUT    /api/v2/todos/:id

useMutation<Todo>({ collection: 'todos', url: '/api/v2/todos/:id', operation: 'delete' })
  .mutate({ _id })                                  // DELETE /api/v2/todos/:id

An insert mints its own _id; update and delete require one on the variables. operation is not inferred from the presence of _id — a caller who passes an id on an insert would silently get an update instead.

:id and :collection interpolate, and an insert drops the trailing /:id, so one template covers all three verbs. method: { update: 'PATCH' } overrides the default POST / PUT / DELETE.

Why a template and not a function?

Under optimistic and queued the request is sent by the drain loop — after a route change, a reload, possibly a week later. By then the component that would have held a closure is long gone. A template is data, so it is stored on the queued document and travels with it.

This is also why mutationFn only works under immediate, where the request is made from your component while it is alive.

Callbacks

onMutate, onSuccess, onError and onSettled work as they do in TanStack, with context flowing from onMutate. Per-call callbacks compose with the hook-level ones rather than replacing them, which is how a list row passes a callback that closes over which row was clicked:

tsx
mutate({ _id, done: true }, { onSuccess: () => toast('Saved') })

onSuccess fires on the local commit under optimistic and queued: the write is durable and the screen has updated, but nothing has reached the server yet. onSynced fires when the server confirms — and is best-effort, because the drain may land after this component is gone, in another tab, or on a later run of the app. Anything that must not be missed belongs in useSyncStatus.

Migrating from TanStack? Delete your rollback code

The canonical onMutate + queryClient.setQueryData + onError rollback block has no analogue here and is not needed — the optimism is the database. It will not fail to compile, so grep for queryClient before calling a migration done. See Migrating from TanStack Query.

Step 6 — Show the queue

Optimistic writes are this library's biggest hazard: a request that fails after the user has navigated away has no UI left to fail into. Render this somewhere, or a write can be lost with no symptom at all.

tsx
import { useSyncStatus } from '@taladb/react/query'

function SyncBadge() {
  const { pending, failed, online, retry, discard } = useSyncStatus()

  if (failed.length > 0) {
    return (
      <Alert>
        {failed.length} change(s) couldn’t be saved.
        <button onClick={() => failed.forEach((f) => retry(f.id))}>Try again</button>
        <button onClick={() => failed.forEach((f) => discard(f.id))}>Discard</button>
      </Alert>
    )
  }
  if (!online) return <Badge>Offline — {pending} change(s) queued</Badge>
  return pending > 0 ? <Badge>Saving…</Badge> : null
}

discard is not undo

No previous version is stored. Discarding a failed insert deletes the document, since the server never knew it existed. Discarding a failed update or delete keeps the document and stops retrying — the local values stand until the next fetch of a query containing it replaces them.

Step 7 — Declare your request parameters

Optional, and the part that pays for itself fastest. An app already has ?category=book&page=1; without this you write category twice — once for the request, once as a local predicate — and the two drift.

defineParams declares each parameter once, so the request and the collection read the same thing:

tsx
import { defineParams, eq, gte, contains, shape } from '@taladb/react/query'

const bookParams = defineParams({
  category: eq('category'),     // ?category=book  → { category: 'book' }
  minPrice: gte('price'),       // ?minPrice=10    → { price: { $gte: 10 } }
  search:   contains('title'),  // ?search=dune    → { title: { $contains: 'dune' } }
  page:     shape(),            // ?page=1         → nothing local
  sort:     shape(),
})

Then at the call site:

tsx
function Books({ category, page }: { category: string; page: number }) {
  const { data, isPending } = useQuery({
    queryKey: ['books'],
    params: bookParams({ category, page }),
    queryFn: ({ params, signal }) =>
      api.get(`/books?${params.toSearchParams()}`, { signal }),
  })

  if (isPending) return <Spinner />
  return <ul>{data.map((b) => <li key={b._id}>{b.title}</li>)}</ul>
}

One declaration, two consumers: you call toSearchParams(), the library calls toFilter(). It never builds a URL — it only reads the half of the declaration that concerns the collection.

params is appended to queryKey, so different parameters are a different query with its own result set and its own freshness. The collection is still taken from queryKey[0].

Start with a plain object

params also accepts a plain object, and that is a fine place to start:

You writeYou get
params: { category, page }Query key + toSearchParams() in queryFn. Zero setup.
params: bookParams({ category, page })The above, plus an indexed local filter.

Same option, same call shape, one import to upgrade — so nobody has to predict at the start whether they will need the filter.

Nothing is inferred, on purpose

An undeclared parameter reaches the key and the request and contributes no local predicate. That asymmetry is deliberate.

There is no reliable way to know a collection's field names — schemas are Standard Schema values with no portable key enumeration, and sampling documents fails exactly when it matters most, on a cold empty collection. So "a parameter whose name matches a field is a predicate" would guess, and guessing wrong is silent: page: 1 treated as a predicate matches nothing and renders an empty list with status: 'success'.

Erring the other way is safe. A local filter that is broader than the server's shows one document too many — recoverable, and in practice invisible, because the query's stored id list still bounds the result. A filter that is narrower shows an empty screen over a full database.

shape() is how page and sort say "deliberately not a predicate" rather than being silently dropped.

Why translate at all?

The id list already records what the server returned, so the parameter filter is usually redundant. Two moments where it is not, and they are the reason this exists:

A local write moves a document out of view. Edit a book's category to stationery while the category=book query is on screen and it leaves immediately. Against the raw id list it would linger until a refetch, because membership was decided by the server before your edit existed.

A cold key, offline. With no record for { category: 'book', page: 2 } there is nothing to render and no network to ask — a spinner over a database that may well hold matching books. The parameters describe what was wanted, so there is a real query to run locally:

tsx
const { data, fromCollection } = useQuery({
  queryKey: ['books'],
  params: bookParams({ category, page }),
  queryFn: ({ params, signal }) => api.get(`/books?${params.toSearchParams()}`, { signal }),
  offline: 'collection',
})

return (
  <>
    {fromCollection && <Banner>Showing results saved on this device</Banner>}
    <BookList books={data} />
  </>
)

This changes what membership means, so it is opt-in and reported. offline: 'strict' — the default — keeps the guarantee that data is exactly what the server returned: no record, no answer.

A local query has no page 2

shape() parameters cannot be honoured offline. Under 'collection' with page set you will get more results than the request would have returned, and a development warning saying so.


Reference — <QueryProvider>

PropTypeDefaultDescription
headers() => HeadersInit | Promise<HeadersInit>Headers for every outbound write, resolved at send time so a queued write carries a current token.
classify(response, op) => WriteOutcomesee belowMap a response onto what the queue should do.
fetchtypeof fetchglobalOverride the fetch implementation, for tests or instrumentation.
drainDrainOptions{ policy: 'auto' }When queued writes are sent. See drain policies.
resolveCollection(queryKey) => string | undefinedWork out the collection for a key that does not lead with it. Returning undefined falls back to queryKey[0].
defaultOptions{ queries: {…} }Defaults for every useQuery below this provider.

defaultOptions.queries

Accepts staleTime, retry, retryDelay, refetchOnMount, refetchOnWindowFocus, refetchOnReconnect, refetchInterval, refetchIntervalInBackground, networkMode, forgetAfter and throwOnError — each described in the useQuery table below. A hook option always wins.

tsx
<QueryProvider defaultOptions={{ queries: { staleTime: 0, retry: false } }}>

staleTime: 0 here is the one-line way back to TanStack's revalidate-on-every-mount behaviour. retry: false is what most test harnesses want.


Reference — useQuery options

Required

OptionTypeDescription
queryKeyreadonly unknown[]Identifies this query. Object properties are order-insensitive, so { a, b } and { b, a } are one query. Must be JSON-shaped.
queryFn(ctx) => Promise<T[] | T | unknown>Fetches current server state. ctx is { queryKey, signal, meta }; honour signal.

Storage and shape

OptionTypeDefaultDescription
collectionstringqueryKey[0]Which collection fetched documents are stored in. Validated against the provider's registry.
documents(raw) => T[]Selects the documents out of an envelope response.
assemble(docs, raw) => TDatadocuments aloneRebuilds data from the live documents plus the stored response.
select(data) => TDataTransforms what this component sees, without changing what is stored. Safe to write inline.
whereFilter<T>Narrows the result set in the engine, composed with the stored id list as $and. Not part of the query key — one fetch can serve many narrowings.
paramsBoundParams | Record<string, ParamValue>Request parameters. Appended to the query key, handed to queryFn, and translated into a local filter when declared with defineParams.
offline'strict' | 'collection''strict'What to answer with when the key is cold and the fetch fails. 'collection' queries everything cached through the params filter and sets fromCollection.

Freshness and fetching

OptionTypeDefaultDescription
staleTimenumber3_600_000 (1h)How long data is considered fresh. Not TanStack's 0 — a cache that survives reload should not treat every mount as a reason to hit the network.
enabledboolean | (() => boolean)truefalse suppresses the fetch, not the cache read. A cold and disabled query stays pending. A function is re-evaluated per render — the dependent-query shape.
refetchOnMountboolean | 'always'trueRevalidate on mount when stale. 'always' ignores staleTime.
refetchOnWindowFocusboolean | 'always'trueRevalidate when the window regains focus, if stale.
refetchOnReconnectboolean | 'always'trueRevalidate when the network returns, if stale.
refetchIntervalnumber | false | (() => number | false)falsePoll every N ms, regardless of freshness. A function is re-evaluated per render, so polling can stop itself.
refetchIntervalInBackgroundbooleanfalseKeep polling while the tab is hidden.
networkMode'online' | 'always' | 'offlineFirst''online''online' reports fetchStatus: 'paused' instead of failing while offline. Use 'always' when queryFn talks to something local.

Failure

OptionTypeDefaultDescription
retryboolean | number | (failureCount, error) => boolean3How many times to retry. A predicate is how you stop retrying a 404.
retryDelaynumber | (attemptIndex, error) => numbermin(1000·2ⁿ, 30s)Delay between attempts.
throwOnErrorboolean | (error) => booleanfalseRethrow during render, for an error boundary to catch.

While a query is retrying, failureCount climbs but error stays null and status does not become 'error' — a query that is still retrying has not failed yet.

Initial and placeholder data

OptionTypeDefaultDescription
initialDataTFetched | (() => TFetched)Data to start from on a cold key. Persisted — hydrated into the collection exactly as a fetch would be. For SSR payloads and route loaders.
initialDataUpdatedAtnumbernowWhen initialData was true, so it can be born stale and revalidate immediately.
placeholderDataTData | ((previous) => TData | undefined)Shown instead of a pending state and never stored. Sets isPlaceholderData.

keepPreviousData is exported for the common case:

tsx
import { keepPreviousData } from '@taladb/react/query'

useQuery({ queryKey: ['todos', page], queryFn, placeholderData: keepPreviousData })

Lifetime and identity

OptionTypeDefaultDescription
forgetAfternumberInfinityHow long to remember this query's result set after nothing is using it. Not garbage collection — see below.
queryKeyHashFn(queryKey) => stringbuilt-inSerialises a key into the record's identity. Replace only if the default cannot express your keys.
metaRecord<string, unknown>Opaque; passed through to queryFn.

forgetAfter is not garbage collection

Documents are never deleted by this layer. They live in your collections, which your app also reads directly — sweeping them would destroy real user data rather than reclaim a cache. What expires is the record of which documents a query returned: a few hundred bytes. When it goes, the next mount is cold and refetches to re-establish membership.

The default is Infinity, because that record is exactly what makes a warm start warm. Reach for forgetAfter when a result set is genuinely disposable — a search whose terms will never be typed again.

TanStack's gcTime is not accepted — it is a compile error rather than a silent no-op. Nothing here is collected by age, and revalidation is already staleTime's job.

Accepted and ignored

notifyOnChangeProps and structuralSharing compile and do nothing — structural sharing is always on here, and notifyOnChangeProps is a render optimisation with no effect on correctness. Each logs once in development.


Reference — useQuery result

The result is a discriminated union, so if (isPending) and if (isError) narrow data for you.

FieldTypeDescription
dataTData | undefinedLive documents, resolved from the collection. undefined until the first result — never [].
errorError | nullTyped Error, not unknown. Non-Error throws are wrapped.
status'pending' | 'success' | 'error'Whether there is data.
fetchStatus'fetching' | 'paused' | 'idle'Whether a request is happening.
isPendingbooleanstatus === 'pending'.
isSuccess / isErrorbooleanMirrors status.
isLoadingbooleanisPending && isFetching — a first load with nothing to show.
isFetchingbooleanA request is in flight.
isRefetchingbooleanFetching over data already on screen.
isPausedbooleanOffline, so the fetch has not been attempted.
isStalebooleanPast staleTime. Not "currently revalidating" — that is isFetching.
isFetched / isFetchedAfterMountbooleanWhether a fetch has completed, ever / since mount.
isPlaceholderDatabooleandata is a stand-in.
fromCollectionbooleandata came from the whole collection rather than this query's result set — see offline: 'collection'.
isLoadingErrorbooleanFailed with nothing cached — the blank-screen case.
isRefetchErrorbooleanFailed over data that is still on screen.
dataUpdatedAtnumberEpoch ms of the last successful fetch.
errorUpdatedAtnumberEpoch ms of the last failure.
failureCountnumberConsecutive failed attempts, reset on success.
failureReasonError | nullThe most recent failure, including during retries.
errorUpdateCountnumberTotal failures over the query's life.
refetch(opts?) => Promise<QueryResult>Fetch now. Resolves once the fetched documents are on screen, so const { data } = await refetch() sees them. opts.cancelRefetch defaults to true — abandon any request already on its way; false joins it instead.

Reference — useMutation

OptionTypeDefaultDescription
collectionstringrequiredWhich collection the write applies to.
urlstringprovider's backendURL template — /api/v2/todos/:id.
methodPartial<Record<SyncOp, string>>POST/PUT/DELETEVerb overrides per operation.
mode'optimistic' | 'immediate' | 'queued''optimistic'When the network happens.
OptionTypeDefaultDescription
retryboolean | number | fn0Not the queries' 3. A read is idempotent; a write may not be. immediate only.
retryDelaynumber | fnexponentialAs useQuery.
networkMode'online' | 'always' | 'offlineFirst''online'Under immediate, 'online' pauses while offline and goes on reconnect.
throwOnErrorboolean | fnfalseRethrow during render, for an error boundary.
scope{ id: string }Run mutations sharing an id one at a time. Prevents two rapid submits racing.
mutationFn(variables) => Promise<T>immediate only. Owns the request; url/method/classify do not apply.
Result fieldTypeDescription
mutate(variables, callbacks?) => voidFire-and-forget. Errors surface on error.
mutateAsync(variables, callbacks?) => Promise<T>Awaitable. Under optimistic, resolves on the local commit.
dataT | undefinedThe stored document.
status'idle' | 'pending' | 'success' | 'error'idle has no useQuery equivalent.
isIdle / isPending / isSuccess / isErrorbooleanisPending stays false under optimistic — that is the point.
isPausedbooleanOffline, waiting for the connection.
variablesTVariables | undefinedWhat the last mutate was called with.
reset() => voidBack to idle.
submittedAtnumberEpoch ms of the last mutate.
failureCount / failureReasonReset on success.
errorError | nullTyped Error, so error.message compiles.

Callbacks

onMutate, onSuccess, onError and onSettled behave as in TanStack, with context flowing from onMutate. Per-call callbacks compose with the hook-level ones rather than replacing them:

tsx
mutate({ _id, done: true }, { onSuccess: () => toast('Saved') })

onSuccess fires on the local commit under optimistic and queued — the write is durable and the UI has updated, but nothing has reached the server. onSynced fires when the server confirms, and is best-effort: the drain may land after this component is gone. Anything that must not be missed belongs in useSyncStatus.

The three modes

modeWhat happens
ModeUI updates
------
optimistic (default)instantly, on the local commit
queuedinstantly, on the local commit
immediateafter the server agrees

queued does not make the user wait. Like optimistic it commits locally and re-renders every query over those documents; the only thing it changes is when the request leaves. It is a request-count knob for high-volume writes — reading progress on every page turn, cursor positions, telemetry — not a latency one.

Use immediate where showing success before the server confirms would mislead: checkout, payment, anything irreversible. Nothing is stored until it succeeds, so there is nothing to roll back if it fails. This is also the one mode that accepts mutationFn, because the request is made from your component while it is still alive:

tsx
const { mutateAsync, isPending } = useMutation<Order>({
  collection: 'orders',
  mode: 'immediate',
  mutationFn: async (cart) => {
    const response = await fetch('/api/orders', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(cart),
    })
    if (!response.ok) throw new Error(`Checkout failed (${response.status})`)
    return response.json()
  },
})

await mutateAsync(cart)  // throws if the server says no

Given a mutationFn, url, method and the provider's classify do not apply — the function owns the request, and whatever it resolves to is stored. Passing one in any other mode throws on the first render, because there would be no closure left for the drain to call.

Or without one, routing by template as the other modes do:

tsx
const { mutateAsync, isPending } = useMutation<Order>({
  collection: 'orders',
  url: '/api/orders/:id',
  mode: 'immediate',
})

Writes are addressed by id

operation: 'update' and 'delete' need an _id on the variables, never a filter. On a secondary browser tab a filter-based write is forwarded to the primary and re-evaluated there against authoritative data — so { done: false } could affect a different set of documents than the one your UI just showed.

When writes are sent

Timing lives on the provider, not the call site:

tsx
<QueryProvider drain={{ policy: 'interval', intervalMs: 30_000 }}>
PolicyBehaviour
autoDefault. Sends an optimistic write as soon as it commits.
intervalBatches on a timer (intervalMs, default 30s).
online-onlyWaits for connectivity.
manualOnly when you ask.

A queued mutation ignores auto and waits for a trigger either way — that is the difference between it and optimistic. It does not make the UI wait: like optimistic, it commits locally first and every query over those documents re-renders immediately. The only thing it changes is when the request leaves.

The timer is the fallback, not the mechanism

The moment a queue most needs draining is the moment the interval is least likely to be about to fire, so four other things trigger a cycle:

TriggerOptionWhy
Tab hiddenflushOnHide (default on)The user switched away or backgrounded the app. The page is still alive, so this is a normal drain.
Tab closingflushOnHideA last-ditch pagehide flush — see below.
Reconnectalways on except manualA backlog built offline is exactly what is waiting.
Queue depthflushAt (default 100)Stop accumulating. Without it a burst sits unsent for the whole interval and the queue grows without bound between ticks.

batch caps how many documents one cycle sends.

The pagehide flush is best-effort by construction

An ordinary fetch is cancelled the moment the document goes away, so the closing flush is sent with keepalive: true. Browsers cap keepalive bodies at 64 KB across all in-flight requests, so a large queue will not fit. It is genuinely a last chance, not a guarantee — which is fine, because the writes are already durable and the next session drains them. The flush on hidden is the one that does the real work.

Choosing an interval

A longer interval does not risk losing writes — they are committed locally and survive reload, crash and flight. What it costs is staleness on other devices and delay before a terminal failure surfaces. Five minutes is fine for reading progress or telemetry, and clearly wrong for a checkbox someone else is watching, which is why the default is 30 seconds.

That second cost is worth dwelling on: the longer the interval, the longer between a user acting and a rejection appearing. useSyncStatus is what catches it, and it matters more as the interval grows, not less.

Write retries back off at 1s, doubling to a five-minute ceiling, with ±25% jitter so a fleet of clients does not reconnect in lockstep. terminal outcomes are never retried automatically; they need a decision.


Reference — useSyncStatus

FieldTypeDescription
pendingnumberWrites queued and waiting to send.
failedFailedWrite[]Terminal failures needing a human decision. Each carries collection, id, op, error, attempts.
drainingbooleanA drain cycle is running.
onlinebooleanBrowser connectivity.
retry(id: string) => voidRequeue a failed write.
discard(id: string) => voidDrop the local change and stop trying.

The backend contract

This library owns a local queue and a fetch. It owns no transport, no cursors, and no conflict resolution — your server is yours. In exchange it asks for five things, and breaks in quiet, delayed ways if it does not get them.

#RuleWhat breaks without it
1Accept _id from the body and never reassign itEvery write duplicates on the server
2Writes are idempotentA retry after a timeout silently creates a second row
3Classify errors explicitly (see below)The queue stalls on an error that can never succeed
4Return the stored documentThe local copy drifts, with no symptom until a refetch
5No cross-document ordering requirementsPer-document order is all that is guaranteed

In development every one of these is checked at runtime, and a violation logs a warning naming the rule and its consequence. Those checks are stripped from production builds.

The four outcomes

A response maps onto one of four things the queue can do. The second is the one people forget:

OutcomeMeaning
okApplied. The body is the canonical document.
appliedAlready applied — typically a 409 answering a retry whose first attempt succeeded but never replied. Without this the queue stalls on it forever.
retryMay recover (5xx, network). Backs off and tries again.
terminalWill never succeed (4xx). Parked for a human decision.

By default 2xx succeeds, 409 is already-applied, 5xx/408/429 retry, and any other 4xx is terminal. Override with classify only if your API disagrees:

tsx
<QueryProvider classify={(res) => (res.status === 418 ? 'terminal' : undefined)}>

Multi-tab

The queue starts drains on the storage-owning tab only. Every tab reads the same authoritative state through the owner, but this gate avoids multiple tabs performing the same network work at once.

Ownership can change during a drain, so remote handlers must still use stable operation IDs and treat retries idempotently. See isPrimary() if you have your own background work; use a separate Web Lock when the whole task must be exclusive.


What this layer adds to your documents

Eight reserved fields, on every document it manages:

FieldPurpose
_sync'synced', 'pending', or 'failed' — this is the queue
_opWhat the document still owes the server
_attemptRetry count
_errorLast terminal error
_fetched_atWhen the server last spoke about it
_retry_atWhen it becomes eligible again
_endpointThe URL template this write is going to
_methodThe verb for the current _op

They live on the document rather than in a separate outbox so every queued write is a single atomic commit. Do not use these names in your own schemas — a server response carrying _sync would overwrite the envelope and drop the document out of the queue. Development builds warn if one appears.

One consequence worth knowing: at most one operation is pending per document. A second edit folds into the first rather than queueing behind it, so the server sees the final state, not the sequence that produced it. That is what keeps this small — no dependency graph, no id remapping, no replay log. It also means $inc-style operations cannot be expressed while offline.

Deleting is a tombstone: the document stays until the server confirms, because the record is what carries the queued delete. Queries filter it out.


Next