Skip to main content
The SPA data layer is built on TanStack Query (@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 a QueryClient 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 wraps useQuery. 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.
Three patterns are worth calling out:
  • The query-key factory. settingsKeys is the single source of truth for this feature’s keys. all is the broad key (["settings"]), details() narrows to detail-type entries, and detail(id) identifies one record. Because reads and invalidations both derive their keys from this object, they can never drift apart.
  • The signal. useQuery hands a fresh AbortSignal to queryFn. Forwarding it to fetch cancels a request automatically when the component unmounts or the query refetches, so there are no stale responses and no wasted work.
  • enabled. Setting enabled: Boolean(id) defers the query until an id is actually available. The hook stays idle instead of firing a request for undefined.
A component consumes the hook directly, with no service call and no manual fetch:

The mutation hook

A mutation hook wraps useMutation. 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.
The 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/, and store/ 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.