State management
State is deliberately split three ways. The rule of thumb:
| Kind of state | Tool | Where |
|---|---|---|
| Auth session (tokens, user, active role) | Redux Toolkit | redux/auth.ts — the only slice |
| Server data (everything fetched over HTTP) | TanStack React Query | hooks/api/ (~52 hooks) |
| Small client-only islands | Zustand | store/ (five stores) |
Component-local state stays in useState — none of the three tools is used for
"this drawer is open".
Redux: the auth slice, and nothing else
redux/store.ts registers exactly one
reducer: auth. The slice holds token, refreshToken, user
(the login response's AuthenticatedUserDto, including the permission list) and
activeRole (the UI role lens — see
auth-and-permissions.md). Every reducer persists to
localStorage under the key motorph.auth, and initial state is rehydrated
from there on load — that is what keeps you logged in across refreshes.
Why Redux here, when everything else avoided it? Because the axios layer
needs the token outside React. api/client.ts
calls store.getState().auth in its request interceptor and
store.dispatch(setTokens(...)) / store.dispatch(logout()) in its 401
refresh flow — plain module-level code with no hook context. A Redux store is an
ordinary importable object, which makes that trivial. (Zustand can do this too —
and the portal side does exactly that — but the ERP auth slice predates the
portal and there is no reason to churn it.)
React Query: all server state
Every REST read/write goes through a hook in
hooks/api/, which wraps a thin axios module in
api/ (api-layer.md). Client defaults
are set in main.tsx:
staleTime: 2 min,gcTime: 10 minrefetchOnWindowFocus: false- custom
retry: never retry a 4xx (it's the caller's bug or a permission issue, not a transient failure); otherwise retry once.
The house pattern, using
hooks/api/useEmployees.ts as the
representative example:
export const EMPLOYEES_QUERY_KEY = ['employees'];
export const useEmployee = (id: number | null) =>
useQuery({
queryKey: ['employees', id], // detail keys extend the list key
queryFn: () => getEmployee(id as number),
enabled: id !== null,
});
export const useCreateEmployee = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: EmployeeRequest) => createEmployee(payload),
onSuccess: () => {
// invalidate the whole domain prefix — list, detail, 'all' variants
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_QUERY_KEY });
},
});
};
Conventions to copy:
- One hooks file per domain, exporting a
*_QUERY_KEYprefix constant. - Query keys are hierarchical arrays (
['employees'],['employees', id],['employees', 'me']) so a prefix invalidation catches every variant. - Mutations invalidate in
onSuccessrather than hand-patching the cache. enabledguards parameterized queries instead of conditional hook calls.
The AG Grid exception
Table pages do not fetch rows through React Query. The enterprise grid's
infinite row model calls fetchPage (or the
clientPagedFetch adapter
for unpaged endpoints) directly from its datasource — AG Grid owns its own block
cache (cacheBlockSize / maxBlocksInCache / purgeInfiniteCache()), and
layering React Query's cache under it would mean two caches with independent
invalidation fighting each other. After a mutation, grid pages refresh by purging
the grid cache (refreshAllTabs()), not by query invalidation — details in
ag-grid.md. React Query still handles the satellite data on those
pages (tab counts, saved views, lookup lists).
Zustand: five client-state islands
store/ contains small, purpose-built stores:
| Store | Persisted? | Purpose |
|---|---|---|
portalAuthStore.ts | motorph.portal.auth | Portal customer token + user (the portal's whole auth state) |
portalCartStore.ts | no | Storefront cart lines |
portalWishlistStore.ts | yes | Wishlisted item ids |
sidebarStore.ts | yes | Sidebar collapsed state, per-group open flags, nav scroll position, nav-customization cache |
breadcrumbStore.ts | no | Label override so detail pages can name their dynamic breadcrumb segment |
Why islands and not more Redux? Each of these is an isolated concern with two
or three actions, no cross-slice interaction, and (for the portal) a hard
requirement to be readable outside React —
portalClient.ts reads the token via
usePortalAuthStore.getState() in its interceptor, exactly the same trick the
Redux store enables for the ERP client. Zustand gives that with ~20 lines per
store and built-in persist middleware, no actions/reducers/provider ceremony.
Keeping the portal's auth in its own store also enforces the design rule that the
portal is a separate auth system from the ERP app.