Skip to main content

State management

State is deliberately split three ways. The rule of thumb:

Kind of stateToolWhere
Auth session (tokens, user, active role)Redux Toolkitredux/auth.ts — the only slice
Server data (everything fetched over HTTP)TanStack React Queryhooks/api/ (~52 hooks)
Small client-only islandsZustandstore/ (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 min
  • refetchOnWindowFocus: 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_KEY prefix constant.
  • Query keys are hierarchical arrays (['employees'], ['employees', id], ['employees', 'me']) so a prefix invalidation catches every variant.
  • Mutations invalidate in onSuccess rather than hand-patching the cache.
  • enabled guards 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:

StorePersisted?Purpose
portalAuthStore.tsmotorph.portal.authPortal customer token + user (the portal's whole auth state)
portalCartStore.tsnoStorefront cart lines
portalWishlistStore.tsyesWishlisted item ids
sidebarStore.tsyesSidebar collapsed state, per-group open flags, nav scroll position, nav-customization cache
breadcrumbStore.tsnoLabel 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.