@tanstack/react-query). It owns server state, the data your app reads from and writes to the backend, and it handles caching, background refetching, request cancellation, and cache invalidation for you.
The architectural rule is 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
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 tofetchcancels a request automatically when the component unmounts or the query refetches, so there are no stale responses and 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 where the cache stays honest after a write. setQueryData writes the server’s response straight into the detail cache so the updated record is available immediately, with no extra round trip. invalidateQueries then marks every entry under settingsKeys.all stale, so lists and related views refetch fresh data. Returning that promise keeps the mutation pending until the refetch settles, which lets your UI show a spinner until the screen is fully consistent rather than only until the write returned.
Calling a mutation from a component mirrors the read side:
Conventions that keep the data layer clean
Define one query-key factory per feature (all / details / detail) and derive every key from it, so reads and invalidations stay in sync. Keep all backend calls in hooks/ (or shared/hooks/) and never call fetch from components, services, or stores. Keep services/ pure: they hold domain logic and never touch the network, and if a service needs data, the hook fetches it and passes it in.
In a mutation’s onSuccess, invalidate the affected keys so dependent reads refetch, and return the invalidation promise to keep the mutation pending until the cache is consistent. Forward the AbortSignal from queryFn to fetch so requests cancel on unmount and refetch, and use enabled to defer a query until its inputs (an id, a filter) are ready instead of firing requests for missing values. spa:feature:create writes a read hook, a mutation hook, and a key factory following these patterns, so 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.