05 — Three kinds of state, on purpose
Read this first: you open redux/store.ts expecting a big app's worth of reducers and find
exactly one. This lesson reads the three state containers this frontend actually runs — Redux,
React Query, Zustand — and shows that each exists for one job the other two cannot do. By the end
you can point at any value on screen and say which container owns it, and why.
Time: about 20 minutes. Assumes lesson 04.
One reducer, on purpose
Here is the entire Redux store:
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './auth';
export const store = configureStore({
reducer: {
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Eleven lines, one slice. This is not an app that has not grown into Redux yet — it is an app of a hundred-plus routes that decided Redux gets auth and nothing else. The decision is written down in ADR-0009, and its map is short:
- Redux Toolkit — the auth slice only.
- TanStack React Query — all server state, through the
hooks/api/wrappers you read in lesson 04. - Zustand — small UI islands, plus the customer portal's entirely separate session.
The ADR is blunt about why server data stays out of Redux: caching, invalidation, and background refetch in Redux is "the classic mistake the ecosystem moved away from." And it is equally blunt about the cost of the split — three idioms, and:
Misplaced state (server data in Zustand, UI state in Redux) compiles fine.
The rule lives in docs and review, not in tooling. That is exactly why this lesson exists.
Why auth, specifically
The auth slice's state shape, from redux/auth.ts:
export interface AuthState {
token: string | null;
refreshToken: string | null;
user: AuthenticatedUserDto | null;
activeRole: string | null;
}
Four fields, persisted to localStorage. activeRole is the role-view switcher from
lesson 03; the other three are the session itself.
The load-bearing requirement: this state must be readable and writable outside React. The axios interceptors from lesson 04 are plain modules — no component tree, no hooks, no context. Yet every request needs the token:
apiClient.interceptors.request.use(config => {
const { token } = store.getState().auth;
if (token) {
config.headers.set('Authorization', `Bearer ${token}`);
}
return config;
});
store.getState() and store.dispatch() work from any module, because the Redux store is a plain
singleton object. A React Query cache or a context value is only reachable through the tree.
That single property is why Redux survives here at all — the ADR concedes it is "a heavyweight
dependency for one slice" that "stays only because of the outside-React access pattern."
Even this small slice carries a defensive scar. Rehydrating from localStorage does not trust the
persisted activeRole:
// A persisted role view the user no longer qualifies for (same subset rule as
// useAuth.availableRoles) must not survive the reload.
Demote a user, and their saved role view dies on the next reload instead of resurrecting permissions they no longer hold.
One deliberate oddity from the ADR: the customer portal has its own auth, and it is Zustand, not Redux. It is a separate system — 24-hour JWT, no refresh rotation — and keeping it out of the ERP auth slice keeps the two from being confused. Two auth code paths, by design, written down.
An island, whole
Here is a complete Zustand store — store/breadcrumbStore.ts, quoted in full:
import { useEffect } from 'react';
import { create } from 'zustand';
interface BreadcrumbStore {
labelOverride: string | null;
setLabelOverride: (label: string | null) => void;
}
export const useBreadcrumbStore = create<BreadcrumbStore>()((set) => ({
labelOverride: null,
setLabelOverride: (label) => set({ labelOverride: label }),
}));
/**
* Lets a detail page name its dynamic breadcrumb segment
* (e.g. `useBreadcrumbLabel(`Run #${id}`)` on /payroll-runs/:id).
*/
export const useBreadcrumbLabel = (label?: string) => {
const setLabelOverride = useBreadcrumbStore(s => s.setLabelOverride);
useEffect(() => {
setLabelOverride(label ?? null);
return () => { setLabelOverride(null); };
}, [label, setLabelOverride]);
};
No provider, no reducer, no action types — create() returns a hook and you are done. This is
what "island" means: one value, two functions, twelve lines of store.
The problem it solves is a distance problem. The breadcrumb trail renders in the app header; the
page that knows what /payroll-runs/42 should be called is the detail page, far away in the
tree, and it only knows after its query resolves. Threading that label up through props would drag
half the shell into the conversation. The island is a cheap cross-tree channel: the page publishes,
the header subscribes.
Note the useEffect cleanup — the override resets to null on unmount. Without it, navigating
from one detail page to another would flash the previous page's label onto the new trail.
The consumer, and the fallback it protects
The header side, in ui/layout/breadcrumbs.tsx:
export const AppBreadcrumbs = () => {
const location = useLocation();
const labelOverride = useBreadcrumbStore(s => s.labelOverride);
const crumbs = buildTrail(location.pathname, labelOverride);
// …
and inside buildTrail, the override lands exactly one place — the trailing segment the nav
config cannot name:
// Trailing segments not in the nav (e.g. /payroll-runs/:id) become the current crumb
const tail = segments.slice(matchedDepth);
if (tail.length > 0) {
crumbs.push({ label: labelOverride ?? humanize(tail[tail.length - 1]) });
}
Static crumbs come from the nav config; only the dynamic :id tail consults the island, and if no
page has published a label the raw segment is humanized as a fallback. The store never becomes a
general "breadcrumb state" — it is one override slot with a default.
The island that caches server data — and says so
The sidebar store bends the map in one place, and the code admits it in-line:
/** Local cache of the user's nav customization, for instant paint before the backend responds.
* useNavPreference (hooks/api/useNavPreference.ts) is the source of truth once it loads. */
navOverride: NavLayoutOverride;
Server data in a Zustand store — the exact misplacement the ADR warns about — except it is a cache with a named source of truth, kept so the sidebar paints in the user's layout on the first frame instead of jumping when the backend answers. A rule bent for a measured reason, documented at the point of the bend, is the difference between a decision and an accident.
The same store shows the persistence idiom:
{
name: 'motorph.sidebar',
partialize: (s) => ({
navGroupOpenStates: s.navGroupOpenStates,
sidebarCollapsed: s.sidebarCollapsed,
navOverride: s.navOverride,
}),
},
persist writes the store to localStorage under motorph.sidebar, and partialize chooses what
survives a reload. navScrollTop is in the store but not in the list — scroll position should
outlive a route change, not a browser restart.
Predict: where do the rows live?
Predict: you are on the Employees page and you scroll the grid; two thousand rows have passed through the viewport. By the three-store map — Redux, React Query, Zustand — where does the employees list live right now? Write your answer down before reading on.
The honest answer: in none of them. Grid pages do not call useQuery for their rows. They hand
a fetchPage function to AG Grid's infinite row model, and the rows live in the grid's own block
cache — fetched per viewport, evicted by the grid, never entering a JavaScript store you own. The
three-store map covers the session, the server-data hooks, and the UI islands; the single biggest
dataset on screen belongs to a fourth owner. That is not a violation of the split — it is the next
design problem, and lesson 07 starts there.
Where this shows up in MotorPH
- frontend/src/redux/store.ts — the one-reducer store
- frontend/src/redux/auth.ts — the auth slice and its rehydration guard
- frontend/src/api/client.ts —
store.getState()from outside React - frontend/src/store/breadcrumbStore.ts and frontend/src/store/sidebarStore.ts — the UI islands
- frontend/src/store/portalAuthStore.ts — the portal's deliberately separate auth
- frontend/src/ui/layout/breadcrumbs.tsx — the island's consumer
- ADR-0009 — the split, with its costs written down
For the exhaustive store-by-store reference — every Zustand store, every persisted key — see ../frontend/state.md.
Recap
- Redux holds auth and only auth — the axios interceptors live outside React, and a plain store singleton is the only container they can read and dispatch against.
- Server state lives in React Query, never in a store you write — hand-rolling cache and invalidation in Redux is the classic mistake ADR-0009 names.
- Zustand is for islands — one value and its setters, no provider, used where two distant parts of the tree need a channel, like a detail page naming its own breadcrumb.
- The split is convention, not tooling — misplaced state compiles fine, so when you bend the
map (the sidebar's
navOverridecache), name the source of truth in a comment at the bend. - Grid rows live in none of the three — AG Grid's infinite row model owns them, which is where the next part of this course begins.
Next: 06 — The shell and the theme.