# ADR 054: TanStack Query for Client-Side Server State

- HTML version: https://robbiepalmer.me/projects/recipe-site/adrs/054-tanstack-query-for-client-server-state
- Project: Recipe Site (https://robbiepalmer.me/projects/recipe-site.md)
- Status: Proposed
- Date: 2026-07-19

# Summary

Adopt **[TanStack Query](https://tanstack.com/query/latest)** for remote server state used by
interactive recipe Client Components. It will own the lifecycle of recipe API reads and writes:
pending/error/success state, request deduplication, cache freshness, background refetching,
pagination, mutation state, invalidation, and optional optimistic updates.

TanStack Query will not own Better Auth's session, local interface state, authorization, or the
canonical recipe data. It complements, rather than replaces, a future move to server-side
rendering: request-time Next.js code can prefetch critical queries and hydrate the same cache that
continues managing the application in the browser.

# Context

The recipe UI began as a static catalogue and now reads user-specific data from the `recipe-api`
Worker: saved recipes, recipe-box selection, diet preferences, households, notifications, profiles,
feeds, and other authenticated resources. Each feature currently implements some combination of
`useEffect`, `useState`, `AbortController`, loading/error state, refetch events, and manual cache
coordination.

That code is not merely HTTP transport. It is a growing, distributed implementation of a
**server-state synchronization cache**. Server state differs from local React state because it:

* is owned remotely and reached asynchronously;
* can become stale without any local state transition;
* may be read concurrently by several components;
* must be reconciled after mutations;
* needs pagination, retry, cancellation, garbage-collection, and focus/reconnect policies;
* must never reuse private data across authenticated users.

The current static export also creates an important boundary. Client-side caching can make the
authenticated SPA coherent after JavaScript starts, but it cannot inspect an `HttpOnly` session
cookie while generating static HTML. TanStack Query can delay the need for SSR by producing a good
shell-to-application transition; it cannot make the first static response personalized.

# Decision

## Ownership boundaries

| State                                         | Owner                            | Examples                                                             |
| --------------------------------------------- | -------------------------------- | -------------------------------------------------------------------- |
| Auth session                                  | Better Auth                      | identity, session pending/error, sign-in and sign-out                |
| Remote recipe server state                    | TanStack Query                   | recipe box, diet, recipes, households, notifications, feeds          |
| Ephemeral UI state                            | React state/context or URL state | open drawer, active tab, unsaved form fields, search/filter input    |
| Durable truth and authorization               | `recipe-api` + Postgres          | recipes, membership, diet profile, permissions, notification records |
| Initial request rendering after SSR migration | Next.js Server Components        | validated session and critical above-the-fold query data             |

Do not mirror query results into component state or a second global store. Derived views such as a
diet-filtered recipe list remain pure computations over query data. Better Auth remains the only
session authority; an authenticated query is enabled only after its user ID is known.

## Integration shape

Mount one `QueryClientProvider` at the recipe application layout boundary, not the personal-site
root. This keeps the dependency and cache out of unrelated personal-site routes. Existing typed
functions under `ui/lib/api` remain the query and mutation functions; TanStack Query coordinates
them but does not replace the API layer.

Define query options and keys centrally enough to prevent naming drift while keeping them beside
their domain:

```ts
export const recipeBoxQuery = (userId: string) =>
  queryOptions({
    queryKey: ["recipe-box", userId],
    queryFn: ({ signal }) => getRecipeBoxProfile(signal),
    staleTime: 5 * 60_000,
  });
```

All private query keys include a stable user identifier or live beneath a user-scoped key prefix.
Sign-out cancels private in-flight queries and removes private cached data. Persisting the query
cache to `localStorage` or IndexedDB is out of scope until private-data retention and shared-device
behavior receive a separate security decision.

## Loading semantics

The cache must preserve the auth-gating invariant:

1. session pending → neutral recipe shell;
2. confirmed anonymous → public experience;
3. confirmed authenticated + critical queries pending → personalized shell skeleton;
4. critical queries ready → personalized application;
5. background refetch → keep correct cached content visible and expose only subtle refresh state.

Do not use `initialData` or `placeholderData` to insert an empty recipe box, the full static
catalogue, or a fallback diet when those values are semantically unknown. Those values assert
meaning the server has not supplied and can produce incorrect intermediate screens. A skeleton is
more honest than confidently displaying another state.

Use a single bootstrap query if measurements show that the initial collection of authenticated
requests is latency- or connection-bound. The bootstrap endpoint may aggregate recipe-box
selection, diet, the first saved-recipe page, and unread count, while later pages and secondary
details retain independent query keys. Aggregation is an API optimization, not a prerequisite for
adopting the client cache.

## Freshness and failure policy

TanStack Query's defaults are useful but must not silently become product policy. By default,
cached queries are stale immediately, stale active queries may refetch on mount/focus/reconnect,
inactive queries are garbage-collected, and failed client queries retry three times. Configure
resource classes explicitly:

| Resource class                                  | Starting policy                         | Reason                                                            |
| ----------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------- |
| Diet option catalogue/reference data            | long `staleTime` (for example one hour) | changes rarely; invalidate after catalogue deployment if required |
| User recipe box, saved recipes, diet, profile   | several minutes + mutation invalidation | local writes are known; cross-device changes may arrive later     |
| Notifications and collaborative household views | short `staleTime`, refetch on focus     | other users/devices can change them                               |
| Discover/public feeds                           | short `staleTime` with pagination       | freshness matters, but cached navigation should remain fast       |
| Authorization failures (`401`/`403`)            | no retry; reconcile/refetch session     | repeated requests cannot repair identity or permissions           |
| Validation/not-found failures (`400`/`404`)     | no retry                                | deterministic client/request result                               |
| Network and selected `5xx` failures             | bounded retry with backoff              | transient failure may recover                                     |

Use query invalidation after successful mutations as the default consistency mechanism. Apply a
direct cache update when the server returns the canonical updated entity. Reserve optimistic
updates for high-value, easily reversible interactions; rollback code and concurrent optimistic
mutation behavior are real complexity, not a default flourish.

## Relationship to SSR

TanStack Query is not an alternative to request-time rendering. The eventual SSR path is:

1. create a new Query Client for each server request;
2. validate the session and prefetch only critical authenticated queries;
3. dehydrate successful query state into a `HydrationBoundary`;
4. render the personalized HTML from that data;
5. hydrate one browser Query Client and continue with queries, mutations, and invalidation;
6. set a non-zero SSR `staleTime` so hydrated data is not immediately fetched again.

Per-request Query Clients are mandatory on the server so cached private data cannot cross request
or user boundaries. Secondary data can remain client-fetched or stream behind Suspense boundaries.
TanStack Query's cache hydration is data-cache transfer; it is distinct from React DOM hydration
and does not by itself prevent markup mismatches.

[Next.js recommends](https://nextjs.org/docs/app/getting-started/fetching-data) Server Components for
request-time data and names React Query/SWR for Client Component fetching. TanStack Query documents
the corresponding [server rendering and hydration
model](https://tanstack.com/query/latest/docs/framework/react/guides/ssr) and warns about request
[waterfalls](https://tanstack.com/query/latest/docs/framework/react/guides/request-waterfalls).

# Alternatives Considered

| Alternative                                                       | Strengths                                                                                                                                                    | Costs / mismatch here                                                                                                                                                                                                                           | Decision                                                                                                                                        |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Manual `useEffect` + `useState` + Context                         | No dependency; completely explicit; sufficient for a few stable requests.                                                                                    | Reimplements cancellation, races, deduplication, freshness, retry, pagination, mutation reconciliation, and user-scoped cleanup throughout the tree.                                                                                            | Reject as the default for remote state; retain for genuinely local state.                                                                       |
| [SWR](https://swr.vercel.app)                                     | Small API; excellent stale-while-revalidate model; cache, focus/reconnect revalidation, Suspense, and mutation support; close alignment with Next.js/Vercel. | More key-and-`mutate` oriented; complex mutation workflows, invalidation conventions, pagination, and broad domain policies require more project-defined structure.                                                                             | Strong runner-up. Prefer it if the application remains predominantly read-heavy and TanStack Query's mutation/query surface proves unnecessary. |
| [RTK Query](https://redux-toolkit.js.org/rtk-query/overview)      | Central API-slice definitions, tag invalidation, generated hooks, Redux DevTools, framework-agnostic core, optional OpenAPI/GraphQL code generation.         | The project does not use Redux. Adding Redux Toolkit and React Redux solely for request caching introduces a store, actions, reducers, provider, and endpoint ceremony with no wider Redux requirement.                                         | Reject unless Redux becomes an independent architectural choice.                                                                                |
| [Apollo Client](https://www.apollographql.com/docs/react) or urql | GraphQL-native operations, schema/code-generation workflows, and normalized entity caches; Apollo offers mature tooling and granular cache policies.         | The recipe API is REST/Hono. Adopting either would also require a GraphQL transport/schema and normalized-cache policy, increasing backend and client complexity to obtain features TanStack Query provides protocol-agnostically.              | Reject while REST remains the API contract.                                                                                                     |
| Next.js Server Components + native `fetch` only                   | Minimal client JavaScript; request-time auth; direct parallel server fetching; streaming and framework cache integration.                                    | Unavailable to personalized pages under the current static export. Even after SSR, interactive client mutations, background refresh, infinite feeds, and cross-component browser cache coordination still need conventions or a client library. | Complement, not substitute. Use for the initial SSR read path later.                                                                            |
| A single bespoke recipe-app provider/bootstrap store              | One coherent initial request and exact domain-specific API; no generic abstraction.                                                                          | Becomes a custom server-state library as pagination, mutation invalidation, background refresh, retries, and multiple resources accumulate.                                                                                                     | A bootstrap endpoint may complement TanStack Query; a bespoke cache should not replace it.                                                      |

General-purpose client stores such as Zustand, Redux without RTK Query, Jotai, or MobX are not direct
competitors for this decision. They can hold remote results, but do not inherently define
freshness, refetch, request deduplication, mutation invalidation, or async garbage collection.

# Consequences

## Positive

* Remote state gains one vocabulary: `isPending`, `isFetching`, `isError`, cached data, and explicit
  invalidation instead of feature-specific state machines.
* Requests with the same user-scoped query key are deduplicated and share cached results.
* Mutations can invalidate every affected view without bespoke global events such as diet-profile
  update events.
* Pagination, prefetching, background refresh, offline-aware network modes, and optimistic updates
  are available when justified rather than reimplemented.
* The same query definitions can be prefetched and hydrated during a later SSR migration.
* Devtools make query keys, observers, freshness, retries, and cache contents inspectable during
  development.

## Negative

* Adds a runtime dependency, provider, cache lifecycle, query-key taxonomy, and new defaults that
  developers must understand.
* Poor keys or broad invalidation can produce stale data, excess requests, or private-data leakage.
* Default immediate staleness, focus refetch, and three client retries can surprise unless
  configured deliberately.
* TanStack Query does not normalize entities across different query responses. The project must
  invalidate/refetch or update each affected cache entry explicitly.
* SSR cache hydration adds server/client serialization and per-request Query Client discipline.
* It can obscure request waterfalls if fetching remains scattered across deeply nested components;
  query orchestration and API aggregation still require design.

# Rollout

1. Add `@tanstack/react-query` and a recipe-scoped provider; include Devtools in development only.
2. Define user-scoped query-key factories and shared retry/error classification before migrating
   components.
3. Migrate one vertical slice—recipe box, saved recipes, and diet—while retaining the neutral
   readiness boundary. Remove replaced effects, abort handling, and custom refresh events.
4. Migrate notifications, households, feeds, and profile resources incrementally. Do not perform a
   flag-day rewrite.
5. Measure request count, time to personalized content, focus refetches, and cache behavior. Add a
   bootstrap endpoint only if it materially flattens the critical path.
6. During a later SSR migration, prefetch/dehydrate only above-the-fold authenticated queries and
   retain TanStack Query for post-hydration interaction.

# Acceptance Criteria

Accept this proposal when a small implementation spike demonstrates that:

* user-scoped cache cleanup is reliable across sign-out and account changes;
* the recipe box reaches one correct ready state without default-data flashes;
* mutations refresh all affected views with less coordination code than the current event/effect
  approach;
* retry and refetch policies do not amplify `401`, `403`, validation, or preview-backend failures;
* the production bundle and runtime overhead are proportionate to the deleted manual machinery;
* the query definitions can be reused by a documented SSR prefetch/hydration path.

# When to Revisit

Revisit the decision if the application moves almost entirely to Server Components with few
interactive client reads, adopts Redux for independent reasons, changes its API contract to
GraphQL, or finds that SWR covers the measured workload with materially less surface area.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
