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:SHORT30s,DEFAULT5min,LONG30min,INFINITE∞CACHE_TIME:SHORT5min,DEFAULT30min,LONG1hREFETCH_TIME:REALTIME5s,FAST30s,DEFAULT60s,SLOW5min
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!" });
toEntrymaps the variables to the outbox{ op, recordId, payload }.applyOptimisticproduces the next cache value; the previous one is restored if the enqueue throws.flush(defaulttrue→"after-mutation") triggers sync;falseleaves it touseOfflineSync.invalidate(defaultfalse) revalidates thequeryKeyinonSettled.
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 viadefaultOptionsorclient.STALE_TIME/CACHE_TIME/REFETCH_TIMEreplace magic numbers with named presets.createQueryKeys(scope, builders)generates typed, consistent keys, with an automaticallfor broad invalidation.- Combine
setQueryData(immediate response) withinvalidateQueries(revalidation) using the same key factory. useOfflineMutationbridges the offline engine to the cache: enqueue + optimistic update + flush + rollback.
See also
- HTTP — the
createApiClientthat powers yourqueryFns - Offline — combine with
initialDatafor an offline fallback - PWA & Offline-First — service worker, background sync, status UI