Skip to content

Query (TanStack Query)

Thin wrappers to standardize cache times, query keys, and the QueryClient. You keep using @tanstack/react-query as usual — the SDK only ships well-calibrated defaults and a typed key factory.

Why do these wrappers exist?

Without standardization, every screen guesses a staleTime and every domain hand-writes queryKey: ["user", id]. That leads to invalidations that miss (a key built differently in two places) and overly aggressive refetching. The SDK centralizes both: named presets plus a factory that guarantees the same key everywhere.

Provider

Wrap the app tree once, usually in main.tsx (or inside <AppProviders>):

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryProvider } from "tempest-react-sdk";
import "tempest-react-sdk/styles.css";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryProvider>
      <App />
    </QueryProvider>
  </StrictMode>,
);

Defaults applied when you do not pass a client:

  • staleTime: 5 min (STALE_TIME.DEFAULT)
  • gcTime: 30 min (CACHE_TIME.DEFAULT)
  • retry: shouldRetryQuery (queries) / 0 (mutations)
  • refetchOnWindowFocus: false

4xx is not retried — and this changed

The default used to be a flat retry: 1, which replayed a 403 on an admin-only listing and a 404 for a deleted record. The server refused on purpose in both cases: the second attempt returns the same answer, doubles the network log and holds the spinner for another round trip.

shouldRetryQuery retries once only what can change on its own: a network failure (status === 0), 5xx, 408, 425 and 429 (a refusal whose literal meaning is "later"), and an error of unknown shape — which may be a transport failure. Every other 4xx fails on the first try.

The classification comes from isRetriableStatus, the same one createApiClient({ retry: true }) and the retry() helper use. Through v0.44.0 this was a local copy missing 425 — so the same 425 Too Early was replayed by the client and not by the query.

If your app relied on retrying 4xx, the override is the usual one: defaultOptions={{ queries: { retry: 1 } }}.

To override, pass defaultOptions (merged on top of the defaults) or a ready-made client (ignores the SDK defaults):

import { QueryClient } from "@tanstack/react-query";
import { QueryProvider, STALE_TIME } from "tempest-react-sdk";

// Option A — tweak only some defaults
<QueryProvider defaultOptions={{ queries: { staleTime: STALE_TIME.LONG } }}>
  <App />
</QueryProvider>;

// Option B — bring your own client (share across roots, plug devtools, etc.)
const client = new QueryClient();
<QueryProvider client={client}>
  <App />
</QueryProvider>;

One QueryClient per app

Do not nest two QueryProviders without passing the same client. Each provider creates an isolated cache, and queries from different subtrees stop sharing data. For multiple roots, create the QueryClient once and pass it via the client prop.

\"No QueryClient set\" with the provider mounted = two copies of react-query

The client you pass is the one place the app's copy and the SDK's copy touch. If npm nested a second copy of @tanstack/react-query under the SDK, QueryClientProvider publishes your client on that copy's context — and every useQuery in the app reads the other copy's, finds nothing, and throws:

No QueryClient set, use QueryClientProvider to set one

With the provider plainly mounted three lines up. Nothing in that message points at the duplicate, which is why it costs an afternoon.

From v0.52.1 the SDK detects it and warns in development: a client from the other copy duck-types perfectly while failing instanceof, which is exactly the right discriminator. The fix is npm dedupe, and npx tempest doctor lists every duplicated dependency.

Time presets

import { useQuery } from "@tanstack/react-query";
import { STALE_TIME, CACHE_TIME, REFETCH_TIME } from "tempest-react-sdk";

useQuery({
  queryKey: ["dashboard"],
  queryFn: fetchDashboard,
  staleTime: STALE_TIME.LONG, // 30 min — data rarely changes
  gcTime: CACHE_TIME.LONG, // 1 h
  refetchInterval: REFETCH_TIME.FAST, // 30 s — status polling
});
  • STALE_TIME: SHORT 30s, DEFAULT 5min, LONG 30min, INFINITE
  • CACHE_TIME: SHORT 5min, DEFAULT 30min, LONG 1h
  • REFETCH_TIME: REALTIME 5s, FAST 30s, DEFAULT 60s, SLOW 5min

When to use INFINITE

STALE_TIME.INFINITE marks data as never stale — TanStack only refetches on manual invalidation. Ideal for static lists (categories, cities) that change per deploy, not per use.

Typed query keys

createQueryKeys takes a scope (the domain prefix) and a map of builders. Every output already carries the scope up front, so the same key is built identically across the whole codebase:

import { createQueryKeys } from "tempest-react-sdk";

export const userKeys = createQueryKeys("user", {
  me: () => ["me"] as const,
  byId: (id: string) => [id] as const,
  list: (filters: { page: number; size: number }) => ["list", filters] as const,
});

userKeys.all; // ["user"]
userKeys.me(); // ["user", "me"]
userKeys.byId("42"); // ["user", "42"]
userKeys.list({ page: 1, size: 20 }); // ["user", "list", { page: 1, size: 20 }]

Note the all: it is generated automatically and is the broadest key of the domain — invalidating it tears down every user/* query at once.

Full example — query + mutation + invalidation

This component reads the profile with useQuery, updates it with useMutation, and invalidates only the affected keys on success:

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { createApiClient, createQueryKeys } from "tempest-react-sdk";

interface User {
  id: string;
  name: string;
}

const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL });

export const userKeys = createQueryKeys("user", {
  me: () => ["me"] as const,
  byId: (id: string) => [id] as const,
});

export function ProfileCard() {
  const queryClient = useQueryClient();

  const { data: user, isLoading } = useQuery({
    queryKey: userKeys.me(),
    queryFn: () => api.get<User>("/users/me"),
  });

  const rename = useMutation({
    mutationFn: (name: string) => api.patch<User>("/users/me", { body: { name } }),
    onSuccess: (updated) => {
      // Update the local cache without a new fetch...
      queryClient.setQueryData(userKeys.me(), updated);
      // ...and invalidate the by-id record, in case another screen uses it.
      queryClient.invalidateQueries({ queryKey: userKeys.byId(updated.id) });
    },
  });

  if (isLoading) return <p>Loading</p>;

  return (
    <div>
      <h2>{user?.name}</h2>
      <button disabled={rename.isPending} onClick={() => rename.mutate("New name")}>
        Rename
      </button>
    </div>
  );
}

Why setQueryData + invalidateQueries?

setQueryData applies the mutation response to the cache immediately (no UI flicker), while invalidateQueries marks related queries as stale to revalidate in the background. Using a key factory guarantees the invalidated key is exactly the one the query consulted.

Organization pattern: each domain in src/constants/query-keys/<domain>.ts, grouped in a barrel.

useOfflineMutation

When the app is offline-first, a mutation should not hit the network directly — it writes to the createOfflineSync outbox and syncs later. useOfflineMutation bridges the sync engine and TanStack Query: on mutate it enqueues the entry, optimistically patches the query cache, kicks a flush, and rolls back the cache if the enqueue fails.

import { useOfflineMutation } from "tempest-react-sdk";
import { notesSync } from "@/sync/engine";
import type { Note } from "@/sync/types";

function useAddNote() {
  return useOfflineMutation<Note, Note[], Note>({
    sync: notesSync,
    queryKey: ["notes"],
    toEntry: (note) => ({ op: "create", recordId: note.id, payload: note }),
    applyOptimistic: (current = [], note) => [...current, note],
  });
}

// const addNote = useAddNote();
// addNote.mutate({ id: crypto.randomUUID(), text: "offline!" });
  • toEntry maps the variables to the outbox { op, recordId, payload }.
  • applyOptimistic produces the next cache value; the previous one is restored if the enqueue throws.
  • flush (default true"after-mutation") triggers sync; false leaves it to useOfflineSync.
  • invalidate (default false) revalidates the queryKey in onSettled.

List-cache helpers

For the common case (the cache is a list), use upsertById() / removeById() instead of hand-writing the spread:

import { upsertById, removeById } from "tempest-react-sdk";

applyOptimistic: upsertById(); // insert or merge by `id`
applyOptimistic: removeById(); // remove by `id` (op "delete")
// custom field: upsertById("uuid")

Server delivery happens on flush

mutate resolves with the outbox entry id, not the server response — actual delivery runs inside the engine's flush loop, so the UI updates instantly and survives reloads and offline periods.

persistQueryClientOffline

Persists the QueryClient cache to IndexedDB and restores it on boot — a reload or a cold offline start shows the last-known data instead of empty screens. Self-contained: uses dehydrate/hydrate from @tanstack/react-query directly, with no @tanstack/react-query-persist-client dependency.

import { persistQueryClientOffline } from "tempest-react-sdk";
import { queryClient } from "@/lib/query";

const persistence = persistQueryClientOffline({ queryClient });
await persistence.restore(); // before the first render

// on logout:
await persistence.clear();
// on teardown:
persistence.unsubscribe();

Writes are throttled (throttleMs, default 1s) and subscribe to the cache. flush() writes immediately; clear() drops the snapshot; unsubscribe() stops persisting. Dexie is a peer dependency of the offline store — install it (npm i dexie).

Recap

  • <QueryProvider> at the root — one per app — ships calibrated defaults; override via defaultOptions or client.
  • STALE_TIME / CACHE_TIME / REFETCH_TIME replace magic numbers with named presets.
  • createQueryKeys(scope, builders) generates typed, consistent keys, with an automatic all for broad invalidation.
  • Combine setQueryData (immediate response) with invalidateQueries (revalidation) using the same key factory.
  • useOfflineMutation bridges the offline engine to the cache: enqueue + optimistic update + flush + rollback.

See also

  • HTTP — the createApiClient that powers your queryFns
  • Offline — combine with initialData for an offline fallback
  • PWA & Offline-First — service worker, background sync, status UI