Skip to Content
FrontendState Management

State Management

Start with Context + SWR. Add Zustand only when you have complex client-only state with frequent updates, undo/redo, or cross-component synchronization.

For Zustand store patterns, persistence, and undo/redo see Zustand recipe. For generated SWR hooks see Data Layer.

The State Management Spectrum

AppPrimary StateZustand?ContextsURL State
carapis/mySWR + ContextNo7 contextsURL-first filters
cmdop/mySWR + ContextNo9 contextsNo
cmdop/docsSWR + ContextNo2 contextsNo
gptkino/studioSWR + Context + Zustand15 stores20+ contextsNo

Context Architecture

Standard Context Template

Every context follows this exact pattern:

'use client'; import { createContext, useCallback, useContext, useMemo, useState } from 'react'; interface MachinesContextType { machines: Machine[]; isLoading: boolean; error: string | null; refresh: () => Promise<void>; createMachine: (data: CreateMachineRequest) => Promise<Machine>; deleteMachine: (id: string) => Promise<void>; } const MachinesContext = createContext<MachinesContextType | undefined>(undefined); export function MachinesProvider({ children }: { children: ReactNode }) { // 1. SWR query const { data, error, isLoading, mutate } = useMachinesMachinesList(params, client); // 2. Derived data with useMemo const machines = useMemo(() => data?.results ?? [], [data]); // 3. Mutation hooks const createMutation = useCreateMachinesMachinesCreate(); // 4. Stable action callbacks const createMachine = useCallback(async (data) => { const result = await createMutation(data, client); await mutate(); // SWR revalidation return result; }, [createMutation, mutate]); // 5. Memoized context value const value = useMemo(() => ({ machines, isLoading, error: error?.message ?? null, refresh: () => mutate(), createMachine, deleteMachine, }), [machines, isLoading, error, mutate, createMachine, deleteMachine]); return <MachinesContext.Provider value={value}>{children}</MachinesContext.Provider>; } // 6. Guard hook export function useMachines(): MachinesContextType { const context = useContext(MachinesContext); if (!context) throw new Error('useMachines must be used within MachinesProvider'); return context; }

The six steps: SWR query, useMemo derived data, mutation hooks, useCallback actions, memoized value, and a guard hook that throws if used outside the provider.

Provider Composition

Small apps use manual nesting in a layout or AppProvider:

// AppProvider.tsx (cmdop/my — 9 providers) export function AppProvider({ children }) { return ( <WorkspacesProvider> <MachinesProvider> <PersonalMachineProvider> <SchedulesProvider> <SkillsProvider> <ActivityProvider> <SystemProvider> <BalanceProvider> <TerminalProvider> {children} </TerminalProvider> </BalanceProvider> </SystemProvider> </ActivityProvider> </SkillsProvider> </SchedulesProvider> </PersonalMachineProvider> </MachinesProvider> </WorkspacesProvider> ); }

Large apps use composeProviders() to avoid deep nesting:

export function composeProviders(providers: ProviderEntry[]): React.FC<{ children: ReactNode }> { return function ComposedProviders({ children }) { return providers.reduceRight<ReactNode>((acc, entry) => { if (Array.isArray(entry)) { const [Provider, props] = entry; return <Provider {...props}>{acc}</Provider>; } return <entry>{acc}</entry>; }, children); }; } // Usage: split into API layer + UI layer const APIProviders = composeProviders(createAPIProviders(projectId)); const UIProviders = composeProviders(UI_PROVIDERS);

Context Decomposition

Each context has a single responsibility. No god-contexts:

CatalogProvider └── FiltersProvider (URL state → API params) └── VehiclesProvider (SWR data fetching + initialData hydration) └── FavoritesProvider (localStorage, max 100) └── RecentlyViewedProvider (localStorage, max 20)

URL-First Filter State

Filters live in URL search params. No Zustand, no useState for filters:

// FiltersContext.tsx const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); const filters = useMemo(() => parseURLFilters(searchParams), [searchParams]); const setFilter = useCallback(<K extends keyof FilterState>( key: K, value: FilterState[K] ) => { const newFilters = { ...filters, [key]: value }; if (key !== 'page') newFilters.page = 1; // Reset page on filter change startTransition(() => { router.push(buildURLFromFilters(newFilters)); }); }, [filters, router]);

Benefits: shareable URLs, browser back/forward works, SSR-compatible.

When to Use What

ToolWhenExample
SWR + ContextServer data with CRUD actionsVehicles list, machines, workspaces
URL state (useSearchParams)Filters, pagination, sortingCatalog filters, search queries
useLocalStoragePersistent local preferencesFavorites, recently viewed, workspace ID
ZustandComplex client-only state, high-frequency updatesVideo editor, canvas, timeline
Event busCross-component dialog triggersOpen dialog from unrelated component

localStorage-Backed State

For persistent local preferences without Zustand:

// Uses useLocalStorage from @djangocfg/ui-core/hooks const [favorites, setFavorites] = useLocalStorage<string[]>('favorites', []); const [recentlyViewed, setRecentlyViewed] = useLocalStorage<ViewedItem[]>('recently_viewed', []); const [currentWorkspaceId, setCurrentWorkspaceId] = useLocalStorage<string | null>('workspace_id', null);

Event Bus

For decoupled dialog communication between unrelated components:

// _lib/events/machines.ts import { events } from '@djangocfg/ui-core/hooks'; export const emitOpenMachineInfoDialog = (payload) => events.publish({ type: 'OPEN_MACHINE_INFO_DIALOG', payload }); // In the dialog host component: events.subscribe('OPEN_MACHINE_INFO_DIALOG', (payload) => setOpen(true));

Rules

  1. Start with Context + SWR — add Zustand only when you need complex client-only state with frequent updates or undo/redo
  2. Follow the 6-step context template: SWR query → useMemo → mutation hooks → useCallback actions → memoized value → guard hook
  3. Use composeProviders() when you have more than ~10 providers to avoid deep nesting
  4. Filters live in URL search params (useSearchParams) — never in useState or Zustand
  5. Each context has a single responsibility — decompose into a provider chain instead of creating a god-context
  6. Use useLocalStorage for persistent client preferences — only reach for Zustand persist when you also need middleware (devtools, undo)
Last updated on