Frontend state management
How Eziseller's Next.js frontend organises client state across React Context, local component state, and cookie/localStorage persistence. Audience: new dev with React experience, no Eziseller context.
1. Overview
Eziseller uses React Context + useReducer/useState as its only client-side state container. There is no Redux, no Zustand, and no React Query / SWR in active use. zustand@5 is listed in package.json dependencies but is not imported anywhere in src/ — it is effectively dead weight pending a future refactor.
Domain state (orders, products, customers, etc.) is not cached globally. Each page/component fetches through axios wrappers in src/lib/api/* on mount and keeps the result in local useState. Cross-cutting concerns — auth, cart, theme, toasts, navigation loading — live in Context providers mounted in the app root.
Persistence is split between cookies (auth, via js-cookie) and localStorage (cart, theme). Server Components cannot read either, so all stateful UI is marked "use client".
2. Architecture
Providers wrap the tree once; components consume via typed useAuth(), useCart(), useTheme(), useToast() hooks. Domain fetches bypass Context entirely — state lives in the leaf component.
3. Contexts
| Context | File | Purpose | Storage |
|---|---|---|---|
AuthContext | src/contexts/AuthContext.tsx | User identity, login/signup/logout, profile + logo mutations, session-expired event handling | Cookies |
CartContext | src/contexts/CartContext.tsx | Buyer-facing catalog cart (items, quantity, total) for /catalog/[slug]/* | localStorage |
ThemeContext | src/contexts/ThemeContext.tsx | Theme mode (light/dark/system) and colour palette | localStorage |
ToastContext | src/contexts/ToastContext.tsx | Imperative toast.success/error/... API; stores active toast list | In-memory only |
NavigationLoadingContext | src/contexts/NavigationLoadingContext.tsx | Global route-transition spinner; 8s fallback auto-stop, 300ms minimum display | In-memory only |
AuthContext is the only one built on useReducer (action types: SET_LOADING, LOGIN_SUCCESS, LOGOUT, UPDATE_USER, AUTH_ERROR, CLEAR_ERROR). The rest use useState.
4. Zustand stores
None. zustand is declared in package.json but grep for from 'zustand' in src/ returns zero matches. Treat it as a future-use dependency; do not assume any global store exists.
5. Persistence strategy
Cookie keys (src/lib/auth.ts):
auth_token— JWT access token, 1 dayrefresh_token— JWT refresh token, 7 daysuser_data— JSON-serialisedUser, 7 days (acts as an SSR-safe warm cache before/api/auth/profileresolves)admin_auth_token,admin_refresh_token,impersonating_user— saved aside during admin impersonation and restored onstopImpersonation()
localStorage keys:
catalog_cart— array ofCartItemthemeMode—'light' | 'dark' | 'system'themeColors— JSON ofThemeColorstheme— legacy key used bysrc/hooks/useTheme.ts(parallel tothemeMode, not unified)
sessionStorage.impersonate_data is used only by /admin/users → /impersonate handoff.
6. Key files
src/contexts/AuthContext.tsx— reducer, provider,useAuth(), session-expired event listenersrc/lib/auth.ts— cookie helpers, axiosauthApiwith 401 refresh interceptor, login/signup/refresh/logoutsrc/lib/fetch-interceptor.ts— firessession-expiredCustomEvent on global auth failuressrc/contexts/CartContext.tsx— cart reducer +localStoragesyncsrc/contexts/ThemeContext.tsx— theme presets + persistencesrc/hooks/useUser.ts,src/hooks/useDashboardStats.ts— thin wrappers around domain fetches (local state, not global)
7. Env vars & config
None specific to state management. Auth cookie domain and lifetimes are hard-coded in src/lib/auth.ts.
8. Gotchas & troubleshooting
- Cart persists across stores → Cause:
CartContextuses a singlelocalStoragekeycatalog_cartwith no store/slug scoping. Opening catalog A, adding items, then visiting catalog B shows A's items. → Fix planned: key the cart by store slug (catalog_cart:${slug}); seedocs/kb/01-core/catalog/cart.md. - Stale
useAuth()values in callbacks → Cause:AuthContextvalue is rebuilt each render; closures captured insideuseEffect/useCallbackwith empty deps see the initialnulluser. → Fix: includeuserin the dep array, or read from a ref, or dispatch into the reducer rather than reading state. - 401 loops after refresh-token expiry → Cause:
authApiinterceptor retries once with_retryflag; if refresh itself returns 401 the interceptor clears cookies and hard-navigates to/login. Third-party axios instances that don't share this interceptor will loop. → Fix: always import shared clients fromsrc/lib/api/*, never create a rawaxios.createfor authenticated calls. - SSR /
localStoragehydration mismatch → Cause:CartContextandThemeContextinitialise withuseState([])/ default theme, then overwrite fromlocalStoragein a clientuseEffect. First paint shows empty cart / default theme before the effect runs. → Fix: gate cart/theme-dependent UI behind anisHydratedflag, or render a skeleton until the effect fires. getToken()returnsnullon the server → Cause:typeof window === 'undefined'guard. Any code path that runs during SSR (e.g. a Server Component importingsrc/lib/auth.tsindirectly) will see no token. → Fix: keep auth-aware code in"use client"modules; do not pass auth state through props from Server Components.- Three theme keys coexist →
themeMode,themeColors(ThemeContext) andtheme(src/hooks/useTheme.ts). They are not kept in sync. → Fix: treatuseTheme.tsas legacy; preferuseTheme()fromThemeContext. - Impersonation leaks on logout →
clearAuth()wipes admin cookies too, so a mid-impersonation logout loses the admin session.stopImpersonation()must be called first. - Refresh tokens are not server-tracked across restarts — the backend keeps them in an in-memory
Set(seebackend/routes/auth.ts). A backend redeploy invalidates every refresh token; the frontend surfaces this as a forced re-login via thesession-expiredevent.
9. Extension points
Use a new Context when: state is genuinely cross-cutting (touches >3 unrelated routes), changes infrequently, and fits a provider shape. Mount the provider in src/app/layout.tsx (global) or a route-group layout (scoped, like CartProvider under /catalog).
Use component-local useState + an src/lib/api/* call when: data is page-scoped (order list, product detail, dashboard stats). This is the dominant pattern — do not promote it to Context just to avoid prop drilling one level.
Consider Zustand when: you need a store that's consumed by many unrelated components with frequent updates and selector-level subscriptions (e.g., a future multi-tab inbox). The dependency is already installed; add the store under src/stores/ and document it here. Do not introduce Redux.
Do not add React Query / SWR ad-hoc — it would fragment the data-fetching story. Either migrate wholesale or stay on the current axios + useState pattern.
10. Related docs
- authentication.md — JWT flow, refresh tokens, session-expired event
- cart.md — cart item shape and the cross-store contamination bug
- maintenance-mode.md — middleware-level gating that bypasses Context entirely