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 — 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 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

ConceptWhat it does
QueriesRead data with useQuery. Results are cached by query key and refetched in the background when stale.
MutationsWrite data with useMutation. On success you update or invalidate the cache so reads reflect the change.
Query keysA serializable array that identifies a cached entry. A query-key factory keeps reads and invalidations in agreement.
Cache invalidationAfter a write, invalidateQueries marks matching entries stale so they refetch with fresh server data.
CancellationThe signal passed to a query is forwarded to fetch, cancelling in-flight requests on unmount or refetch.

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.
import { useQuery } from "@tanstack/react-query";

export type SettingsType = { id: string; name: string };

// Query key factory — single source of truth for this feature's keys so reads
// and invalidations always agree.
export const settingsKeys = {
  all: ["settings"] as const,
  details: () => [...settingsKeys.all, "detail"] as const,
  detail: (id: string) => [...settingsKeys.details(), id] as const,
};

const getSettings = async (id: string, signal: AbortSignal): Promise<SettingsType> => {
  const response = await fetch(`/api/settings/${id}`, { signal });
  if (!response.ok) throw new Error("Failed to fetch settings");
  return (await response.json()) as SettingsType;
};

export const useGetSettings = (id: string) => {
  return useQuery({
    queryKey: settingsKeys.detail(id),
    queryFn: ({ signal }) => getSettings(id, signal), // signal cancels on unmount/refetch
    enabled: Boolean(id),                              // wait until an id is available
  });
};
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 means a request is cancelled automatically when the component unmounts or the query refetches — no stale responses, 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 — no service call, no manual fetch:
const { data, isPending, isError } = useGetSettings(settingsId);

if (isPending) return <Spinner />;
if (isError) return <ErrorState />;
return <SettingsView settings={data} />;

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.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { settingsKeys, type SettingsType } from "./useGetSettings";

type UpdateSettingsInputType = Partial<Omit<SettingsType, "id">> & { id: string };

const updateSettings = async ({ id, ...input }: UpdateSettingsInputType): Promise<SettingsType> => {
  const response = await fetch(`/api/settings/${id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
  });
  if (!response.ok) throw new Error("Failed to update settings");
  return (await response.json()) as SettingsType;
};

export const useUpdateSettings = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: updateSettings,
    onSuccess: (data) => {
      // Seed the cache with the server response, then invalidate to refetch siblings.
      queryClient.setQueryData(settingsKeys.detail(data.id), data);
      return queryClient.invalidateQueries({ queryKey: settingsKeys.all });
    },
  });
};
The onSuccess handler is the heart of keeping the cache 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 marks every entry under settingsKeys.all stale, so lists and related views refetch fresh data.
  • Returning the promise from invalidateQueries keeps 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.
Calling a mutation from a component mirrors the read side:
const { mutate, isPending } = useUpdateSettings();

const onSubmit = (values: { name: string }) => {
  mutate({ id: settingsId, ...values });
};

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/ (or shared/hooks/). Never call fetch from 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 the AbortSignal from queryFn to fetch so requests cancel on unmount and refetch.
  • Use enabled to 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:create writes 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/, 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.