Error | null;
}
export function useAsyncResource<T>(
fetcher: () => Promise<T>,
dependencies: unknown[] = []
): AsyncResourceState<T> & { refetch: () => void } {
const [state, setState] = useState<AsyncResourceState<T>>({
data: null,
status: 'idle',
error: null,
});
const execute = useCallback(async () => {
setState(prev => ({ ...prev, status: 'loading', error: null }));
try {
const result = await fetcher();
setState({ data: result, status: 'success', error: null });
} catch (err) {
setState({ data: null, status: 'error', error: err as Error });
}
}, dependencies);
useEffect(() => {
let cancelled = false;
execute().then(() => {
if (!cancelled) setState(prev => ({ ...prev, status: 'success' }));
});
return () => { cancelled = true; };
}, [execute]);
return { ...state, refetch: execute };
}
**Architecture rationale:** The hook accepts a fetcher function rather than a URL string. This decouples the hook from HTTP-specific logic, allowing it to wrap GraphQL clients, WebSocket streams, or IndexedDB queries. The `cancelled` flag prevents state updates on unmounted components, eliminating memory leaks without relying on deprecated `isMounted` patterns.
### Step 2: Centralize Complex Transitions with Reducers
When state mutations involve multiple dependent values or require auditability, `useReducer` replaces scattered `useState` calls. The reducer must remain a pure function.
```typescript
import { useReducer, createContext, useContext, useMemo } from 'react';
interface WorkflowState {
entities: Record<string, unknown>;
activeId: string | null;
filters: { status: string; category: string };
}
type WorkflowAction =
| { type: 'SET_ENTITY'; payload: { id: string; data: unknown } }
| { type: 'SELECT_ENTITY'; payload: string }
| { type: 'UPDATE_FILTER'; payload: Partial<WorkflowState['filters']> }
| { type: 'RESET' };
function workflowReducer(state: WorkflowState, action: WorkflowAction): WorkflowState {
switch (action.type) {
case 'SET_ENTITY':
return {
...state,
entities: { ...state.entities, [action.payload.id]: action.payload.data },
};
case 'SELECT_ENTITY':
return { ...state, activeId: action.payload };
case 'UPDATE_FILTER':
return { ...state, filters: { ...state.filters, ...action.payload } };
case 'RESET':
return { entities: {}, activeId: null, filters: { status: 'all', category: 'all' } };
default:
return state;
}
}
Why this choice: Action objects enforce a strict contract for state mutations. TypeScript discriminated unions (type field) guarantee exhaustive handling at compile time. The reducerâs purity enables unit testing without React dependencies and supports time-travel debugging when paired with devtools.
Step 3: Scope Global State with Context Providers
Context should distribute state, not store frequently changing data. Wrapping the reducer in a provider creates a stable distribution layer.
const WorkflowContext = createContext<{
state: WorkflowState;
dispatch: React.Dispatch<WorkflowAction>;
} | null>(null);
export function WorkflowProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(workflowReducer, {
entities: {},
activeId: null,
filters: { status: 'all', category: 'all' },
});
const contextValue = useMemo(
() => ({ state, dispatch }),
[state.entities, state.activeId, state.filters]
);
return (
<WorkflowContext.Provider value={contextValue}>
{children}
</WorkflowContext.Provider>
);
}
export function useWorkflow() {
const ctx = useContext(WorkflowContext);
if (!ctx) throw new Error('useWorkflow must be used within WorkflowProvider');
return ctx;
}
Architecture decision: useMemo stabilizes the context value. Without it, every reducer update creates a new object reference, triggering unnecessary consumer re-renders. The custom useWorkflow hook enforces provider boundaries and provides TypeScript inference automatically.
Step 4: Introduce External Stores for Cross-Cutting Concerns
When state must persist across route changes, survive component unmounting, or require selector-based optimization, external stores like Zustand or Redux Toolkit replace context.
import { create } from 'zustand';
interface SessionStore {
isAuthenticated: boolean;
userId: string | null;
preferences: { theme: 'light' | 'dark'; notifications: boolean };
setAuth: (status: boolean, id: string) => void;
updatePreferences: (partial: Partial<SessionStore['preferences']>) => void;
logout: () => void;
}
export const useSessionStore = create<SessionStore>((set) => ({
isAuthenticated: false,
userId: null,
preferences: { theme: 'light', notifications: true },
setAuth: (status, id) => set({ isAuthenticated: status, userId: id }),
updatePreferences: (partial) =>
set((state) => ({ preferences: { ...state.preferences, ...partial } })),
logout: () =>
set({ isAuthenticated: false, userId: null, preferences: { theme: 'light', notifications: true } }),
}));
Why external stores win here: Zustand uses useSyncExternalStore under the hood, enabling precise selector subscriptions. Components only re-render when the selected slice changes, eliminating the subtree re-render problem inherent to Context. The store survives unmounts, making it ideal for authentication, feature flags, and UI preferences.
Pitfall Guide
1. Context Overuse for High-Frequency Updates
Explanation: Wrapping frequently changing values (e.g., mouse position, scroll offset, typing input) in Context forces every consumer to re-render on each change.
Fix: Isolate high-frequency state in local components or use external stores with selector functions. Reserve Context for low-frequency, application-wide values like locale or theme.
2. Mutating State Inside Reducers
Explanation: Directly modifying state.entities or array references breaks immutability guarantees, causing React to skip re-renders or produce stale closures.
Fix: Always return new object/array references. Use spread syntax or structuredClone for deep updates. Enforce immutability with TypeScriptâs readonly modifiers.
3. Missing Dependency Arrays in Custom Hooks
Explanation: Omitting dependencies in useEffect or useCallback causes stale closures or infinite loops. Including non-stable objects triggers unnecessary effect executions.
Fix: Use useMemo or useCallback to stabilize dependencies. For fetchers, pass stable references or use useRef for mutable values that shouldnât trigger re-runs.
4. Prop Drilling Disguised as Context
Explanation: Creating a Context provider that only passes props through intermediate components adds abstraction overhead without solving the underlying architecture issue.
Fix: If only 2-3 components need the data, prop drilling is faster and more explicit. Context should solve genuine distribution problems, not replace function parameters.
5. Ignoring Serialization Limits in Persistence
Explanation: localStorage and sessionStorage only accept strings. Storing Date, Map, Set, or circular objects silently corrupts data or throws runtime errors.
Fix: Implement explicit serialization/deserialization layers. Use JSON.stringify with replacers, or leverage libraries like superjson for complex types. Validate stored data on hydration.
6. Mixing Synchronous and Asynchronous State Logic
Explanation: Updating state immediately after an async call without loading/error boundaries causes race conditions and UI flicker.
Fix: Separate async orchestration (hooks) from state transitions (reducers/stores). Use status enums (idle | loading | success | error) to model UI phases explicitly.
7. Unmemoized Context Values
Explanation: Passing inline objects or functions to <Context.Provider value={...}> creates new references on every render, breaking consumer memoization.
Fix: Wrap context values in useMemo. Extract dispatch functions or callbacks outside the provider when possible. Use useCallback for stable function references.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Form input with validation | Local useState + custom hook | Zero overhead, isolated scope | None |
| Theme/locale across app | Context API + useMemo | Low update frequency, wide distribution | None |
| Multi-step wizard with dependencies | useReducer + Context | Predictable transitions, audit trail | None |
| Real-time dashboard data | External store (Zustand/Redux) | Selector optimization, survives unmounts | +8â12 KB |
| Cross-route user session | External store + persistence | State survives navigation, syncs tabs | +10 KB |
| Complex form with async validation | Custom hook + useReducer | Separates side effects from state transitions | None |
Configuration Template
// store/workflow.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface WorkflowConfig {
version: number;
lastSync: string | null;
draft: Record<string, unknown>;
}
type WorkflowActions = {
setDraft: (id: string, data: unknown) => void;
clearDraft: () => void;
markSynced: () => void;
};
export const useWorkflowStore = create<WorkflowConfig & WorkflowActions>()(
persist(
(set) => ({
version: 1,
lastSync: null,
draft: {},
setDraft: (id, data) =>
set((state) => ({ draft: { ...state.draft, [id]: data } })),
clearDraft: () => set({ draft: {} }),
markSynced: () => set({ lastSync: new Date().toISOString() }),
}),
{
name: 'app-workflow-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ draft: state.draft, version: state.version }),
}
)
);
Quick Start Guide
- Install dependencies:
npm install zustand (or @reduxjs/toolkit react-redux for enterprise setups)
- Create store file: Define state shape and actions using
create() with TypeScript interfaces
- Wrap root component: Add
<WorkflowProvider> or store initialization at the application entry point
- Consume in components: Import
useWorkflowStore and select only required slices: const draft = useWorkflowStore(s => s.draft)
- Verify behavior: Open React DevTools â Profiler â record interaction â confirm re-renders match expected scope
State architecture is not about choosing the âbestâ library. Itâs about matching state topology to data flow requirements. Local primitives handle isolation. Reducers enforce predictability. Context distributes low-frequency values. External stores optimize high-frequency or cross-cutting concerns. Apply each pattern deliberately, and your application will scale without accumulating technical debt.