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
| App | Primary State | Zustand? | Contexts | URL State |
|---|---|---|---|---|
| carapis/my | SWR + Context | No | 7 contexts | URL-first filters |
| cmdop/my | SWR + Context | No | 9 contexts | No |
| cmdop/docs | SWR + Context | No | 2 contexts | No |
| gptkino/studio | SWR + Context + Zustand | 15 stores | 20+ contexts | No |
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
| Tool | When | Example |
|---|---|---|
| SWR + Context | Server data with CRUD actions | Vehicles list, machines, workspaces |
URL state (useSearchParams) | Filters, pagination, sorting | Catalog filters, search queries |
useLocalStorage | Persistent local preferences | Favorites, recently viewed, workspace ID |
| Zustand | Complex client-only state, high-frequency updates | Video editor, canvas, timeline |
| Event bus | Cross-component dialog triggers | Open 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
- Start with Context + SWR — add Zustand only when you need complex client-only state with frequent updates or undo/redo
- Follow the 6-step context template: SWR query →
useMemo→ mutation hooks →useCallbackactions → memoized value → guard hook - Use
composeProviders()when you have more than ~10 providers to avoid deep nesting - Filters live in URL search params (
useSearchParams) — never inuseStateor Zustand - Each context has a single responsibility — decompose into a provider chain instead of creating a god-context
- Use
useLocalStoragefor persistent client preferences — only reach for Zustandpersistwhen you also need middleware (devtools, undo)