Zustand
The four stores that hold cross-page client state — and why there are only four.
zustand (^5.0.0) is the dashboard's client-side store. There are four stores
today. New stores are rare — most "shared state" should live in the URL or be derived
from a route handler, not in zustand.
The four stores
| Store | File | What it owns | Read by |
|---|---|---|---|
| Fleet | src/store/useFleetStore.ts | Live per-instance health (Node-RED reachability, last-seen, version) | Fleet pages, Sidebar status pip, Galaxy Map site rings |
| Layout | src/store/useLayoutStore.ts | Active Holoprojector layout + per-slot bindings during edit | Holoprojector page, LayoutEditor |
| Toast | src/store/useToastStore.ts | Toast queue (mutation success / error notifications) | ToastContainer mounted in root layout |
| Sense | src/store/useSenseStore.ts | k8s cluster snapshot (nodes, pods, namespaces) for the Force/Sense plane | the-force/Sense pages, reagraph stack graph |
Why so few stores
A store is justified when state is (a) shared across pages and (b) live. Per- page state, derived state, and "happens to be in multiple components on the same page" state should not be a store.
- Per-page transient state —
useStatein the component. - Derived state — a selector or memo over an existing store.
- State that survives a refresh — URL params, then
localStorageas a last resort.
The dashboard has resisted the temptation to add a "feed store", a "panel store", a "republic store", etc. — those are page-local, the route handlers are the source of truth, and re-fetching on mount is cheaper than maintaining cache invalidation.
Selectors, not the whole store
Subscribe by selector to avoid pointless re-renders:
// good — re-renders only when this instance's status changes
const status = useFleetStore(s => s.instances[id]?.status);
// bad — re-renders on any fleet change
const { instances } = useFleetStore();This matters most for components rendered in a loop (every row in the InstanceTable, every ring on the Galaxy Map) where a naive subscription would re-render the entire list on a single instance's heartbeat.
Where each store is populated
| Store | Populated by |
|---|---|
| Fleet | A polling hook in the root layout that calls /api/instances every N seconds and writes deltas |
| Layout | The LayoutEditor on mount; cleared on unmount |
| Toast | notify.ts helpers (notify.ok(...), notify.err(...)) — these are the only writers |
| Sense | A polling hook on the Sense page that calls /api/sense/cluster (or the equivalent) and writes the snapshot |
What zustand is not used for
- HTTP cache. No
react-query, noswr. Route handlers are fast; pages re-fetch on mount. If a future page needs cache, prefer adding a tiny per-page hook over a global store. - Form state. Forms are local
useState. Submission goes straight to a route handler. - Auth state. The Keycloak session cookie is the source of truth; client-side auth
hints come from
jose-verified JWT claims surfaced via a layout-level RSC.
Before adding a fifth store, ask whether the data could live in URL params or be re-fetched per-page. If it must be a store, document its lifetime and writers here.
See also
- Next.js + React — RSC/client split and where routes own their data
- The Force — Sense store consumer
- Holoprojector — Layout store consumer