@tanstack/react-query). It owns server state — the data your app reads from and writes to the backend — with caching, background refetching, request cancellation, and cache invalidation handled for you.
The architectural rule is simple and strict: hooks are the only layer that talks to the backend. Data fetching and API calls live in a feature’s hooks/ (or shared/hooks/). services/ hold pure domain logic and never touch the backend. store/ holds client state, not server data. Keeping these layers separate means your fetch logic, your business rules, and your UI state never bleed into one another.
These hooks call the same backend you build with controllers and routing — they hit /api/... endpoints. See routing and controller for the server side of that contract.
Where the QueryClient lives
TanStack Query needs aQueryClient provided at the root of the app. That provider is wired once at bootstrap in src/bootstrap/app.tsx, so every hook in every feature can read and write the shared cache. You don’t set this up per feature — it’s there from the moment the app starts.
When you run spa:feature:create, @tanstack/react-query is installed automatically and the generator scaffolds two example hooks per feature — a read hook and a mutation hook — plus a query-key factory. You start from working code, not a blank file.
How it works
| Concept | What it does |
|---|---|
| Queries | Read data with useQuery. Results are cached by query key and refetched in the background when stale. |
| Mutations | Write data with useMutation. On success you update or invalidate the cache so reads reflect the change. |
| Query keys | A serializable array that identifies a cached entry. A query-key factory keeps reads and invalidations in agreement. |
| Cache invalidation | After a write, invalidateQueries marks matching entries stale so they refetch with fresh server data. |
| Cancellation | The signal passed to a query is forwarded to fetch, cancelling in-flight requests on unmount or refetch. |
The read hook
A read hook wrapsuseQuery. The generated useGetSettings hook below shows the full shape: a typed result, a query-key factory, a fetch function that takes an AbortSignal, and the hook itself.
- The query-key factory.
settingsKeysis the single source of truth for this feature’s keys.allis the broad key (["settings"]),details()narrows to detail-type entries, anddetail(id)identifies one record. Because reads and invalidations both derive their keys from this object, they can never drift apart. - The
signal.useQueryhands a freshAbortSignaltoqueryFn. Forwarding it tofetchmeans a request is cancelled automatically when the component unmounts or the query refetches — no stale responses, no wasted work. enabled. Settingenabled: Boolean(id)defers the query until anidis actually available. The hook stays idle instead of firing a request forundefined.
The mutation hook
A mutation hook wrapsuseMutation. It imports the query-key factory and types from the read hook, so the write side reuses the exact keys the read side cached under.
onSuccess handler is the heart of keeping the cache honest after a write:
setQueryDatawrites the server’s response straight into the detail cache so the updated record is available immediately, with no extra round trip.invalidateQueriesmarks every entry undersettingsKeys.allstale, so lists and related views refetch fresh data.- Returning the promise from
invalidateQuerieskeeps the mutation in its pending state until the refetch settles. Your UI can show a spinner until the screen is fully consistent, not just until the write returned.
Best practices
- Centralize keys in a factory. Define one query-key factory per feature (
all/details/detail) and derive every key from it. Reads and invalidations stay in sync because they share the same source. - Keep fetch logic in hooks only. All backend calls belong in
hooks/(orshared/hooks/). Never callfetchfrom components, services, or stores. - Keep services pure.
services/hold domain logic and never touch the network. If a service needs data, the hook fetches it and passes it in. - Invalidate by key after writes. In a mutation’s
onSuccess, invalidate the affected keys so dependent reads refetch. Return the invalidation promise to keep the mutation pending until the cache is consistent. - Pass the
signal. Forward theAbortSignalfromqueryFntofetchso requests cancel on unmount and refetch. - Use
enabledto gate on params. Defer a query until its inputs (an id, a filter) are ready instead of firing requests for missing values. - Let the generator scaffold it.
spa:feature:createwrites a read hook, a mutation hook, and a key factory following these patterns — start there and adapt.
See also
- SPA overview — the big picture of the single page app.
- Project structure — where
hooks/,services/, andstore/live. - Features — generating features and their hooks with
spa:feature:create. - SPA routing — wiring features into screens.
- Routing and Controller — the backend endpoints these hooks call.
- Fetcher — the HTTP utility for talking to your API.