ADR-003 — Frontend state: Zustand + TanStack Query
canonical· tech-lead · updated 2026-06-28 · source
- Status: Accepted
- Date: 2026-05-20
- Owner: Tech Lead
- Related issue: #6 (repo scaffold + ADRs)
Context
- Desktop client has two clearly different kinds of state:
- UI state — sidebar collapsed, active view, modals, command palette open, settings, layout splits.
- Server state — issues, integrations, AI runs, notifications, user profile — owned by the API, fetched, cached, invalidated.
- Mixing them in one store (Redux-style) blurs invalidation rules and bloats reducers.
- Tauri v2 + React 19 + a desktop app that lives open 8h/day means cache freshness, focus refetching, and optimistic mutations are first-class concerns.
Decision
- Use Zustand for client UI state.
- One global store + slice pattern (
useUiStore,useSettingsStore,useCommandPaletteStore). - Selectors with
shallowto avoid over-renders. - Persist whitelist (e.g. theme, sidebar width) via
zustand/middleware/persist.
- One global store + slice pattern (
- Use TanStack Query (v5) for all server state.
useQueryfor reads,useMutationfor writes,invalidateQueriesfor cache surgery.- Query keys are typed via
@tedos/sharedconstants (e.g.qk.issues.list(filters)). - SSE / WS push messages from API call
queryClient.invalidateQueriesorsetQueryData.
Rule of thumb
| Lives in… | Examples |
|---|---|
| Zustand | active view, panel widths, modal open/close, theme, command-palette query string, draft form values not yet POSTed |
| TanStack Query | issues list, single issue detail, integrations status, AI run history, user profile, notifications |
React local state (useState) | transient component-only flags (hover, focus-within, animation in-progress) |
Out of scope here: form library (separate ADR, likely React Hook Form), routing (Tauri windows + a small router TBD).
Consequences
Positive
- Zustand is ~1kb, no provider, no boilerplate; reads like plain JS.
- TanStack Query handles every hard cache problem (dedup, retries, focus refetch, pagination, optimistic updates) so we never write a reducer for that again.
- Clean integration story: SSE/WS pushes mutate the query cache directly; UI panels keep using
useQueryand just re-render. - Both libraries are framework-agnostic enough to survive a React Native or web port if we ever do one.
Negative / trade-offs
- Two libraries to learn instead of one (mitigated: both have small surface areas).
- Need a convention for query-key construction — codified in
packages/shared/src/queryKeys.ts. - Devtools are separate (Zustand devtools + TanStack Query devtools) — fine in dev, both have React DevTools-compatible inspectors.
Follow-ups required
- Frontend Engineer ships
packages/shared/src/queryKeys.tsconstants when first endpoint lands. - Designer specifies which UI state surfaces persist across launches (theme yes; modal state no).
Alternatives considered
| Option | Why not |
|---|---|
| Redux Toolkit + RTK Query | Heavier ergonomics, more files per slice, less mindshare in 2026. RTK Query is fine but TanStack Query has better DX and ecosystem. |
| Jotai + TanStack Query | Jotai is great but atom-spaghetti is real on a 50-screen app; Zustand's slice + selector pattern scales better for our team size. |
Plain React Context + useReducer | Re-renders all consumers on every change; no devtools; we would rebuild Zustand poorly. |
| Valtio | Proxy-based magic is harder to reason about for new contributors; Zustand wins on explicitness. |
| TanStack Query alone (no client store) | Forces hacky useQuery(['ui', 'sidebar']) patterns for things that aren't server state. Wrong abstraction. |
References
knowledge/tedos-implementation-plan.md— P1 client features (sidebar, command palette, issue list)knowledge/tedos-design-system.md— React 19 + TS + Tailwind v4 baseline- TanStack Query: https://tanstack.com/query
- Zustand: https://zustand-demo.pmnd.rs