d?*
- Independent UI toggles â
useState
- Multi-variable state with explicit transitions â
useReducer
- External system synchronization (DOM, network, timers) â
useEffect
- Derived values from existing state/props â Inline or
useMemo
Step 2: Implement the Architecture
The following example replaces a tangled coordination pattern with a clean, testable structure. Notice how each hook is isolated to its domain.
import { useState, useReducer, useEffect, useMemo, useCallback } from 'react';
// Domain types
type TaskStatus = 'pending' | 'in_progress' | 'review' | 'completed';
interface TaskPayload {
id: string;
status: TaskStatus;
assignee: string;
}
interface WorkflowState {
tasks: Record<string, TaskPayload>;
activeFilter: TaskStatus | 'all';
isLoading: boolean;
error: string | null;
}
type WorkflowAction =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: TaskPayload[] }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'UPDATE_STATUS'; payload: { id: string; status: TaskStatus } }
| { type: 'SET_FILTER'; payload: TaskStatus | 'all' };
// Pure reducer: zero side effects, fully testable
function workflowReducer(state: WorkflowState, action: WorkflowAction): WorkflowState {
switch (action.type) {
case 'FETCH_START':
return { ...state, isLoading: true, error: null };
case 'FETCH_SUCCESS':
return {
...state,
isLoading: false,
tasks: Object.fromEntries(action.payload.map(t => [t.id, t])),
};
case 'FETCH_ERROR':
return { ...state, isLoading: false, error: action.payload };
case 'UPDATE_STATUS':
return {
...state,
tasks: {
...state.tasks,
[action.payload.id]: {
...state.tasks[action.payload.id],
status: action.payload.status,
},
},
};
case 'SET_FILTER':
return { ...state, activeFilter: action.payload };
default:
return state;
}
}
// Component implementation
export function TaskWorkflow({ projectId }: { projectId: string }) {
const [state, dispatch] = useReducer(workflowReducer, {
tasks: {},
activeFilter: 'all',
isLoading: false,
error: null,
});
// Independent UI state: filter visibility toggle
const [showDetails, setShowDetails] = useState(false);
// Derived computation: no effect needed
const filteredTasks = useMemo(() => {
const all = Object.values(state.tasks);
return state.activeFilter === 'all'
? all
: all.filter(t => t.status === state.activeFilter);
}, [state.tasks, state.activeFilter]);
// External synchronization: fetch on mount + projectId change
useEffect(() => {
const controller = new AbortController();
dispatch({ type: 'FETCH_START' });
fetch(`/api/projects/${projectId}/tasks`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error('Network response failed');
return res.json();
})
.then((data: TaskPayload[]) => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
});
return () => controller.abort();
}, [projectId]);
// Stable callback to prevent child re-renders
const handleStatusChange = useCallback((id: string, newStatus: TaskStatus) => {
dispatch({ type: 'UPDATE_STATUS', payload: { id, status: newStatus } });
}, []);
return (
<div className="workflow-container">
<header>
<select
value={state.activeFilter}
onChange={e => dispatch({ type: 'SET_FILTER', payload: e.target.value as TaskStatus | 'all' })}
>
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="completed">Completed</option>
</select>
<button onClick={() => setShowDetails(prev => !prev)}>
{showDetails ? 'Hide' : 'Show'} Details
</button>
</header>
{state.isLoading && <p>Loading tasks...</p>}
{state.error && <p className="error">{state.error}</p>}
<ul>
{filteredTasks.map(task => (
<li key={task.id}>
<span>{task.assignee}</span>
<select
value={task.status}
onChange={e => handleStatusChange(task.id, e.target.value as TaskStatus)}
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="completed">Completed</option>
</select>
{showDetails && <small>ID: {task.id}</small>}
</li>
))}
</ul>
</div>
);
}
Architecture Decisions & Rationale
useReducer for task state: Task status, loading flags, and error messages are tightly coupled. A reducer enforces explicit transitions, prevents partial updates, and isolates business logic from the UI layer.
useState for UI toggles: showDetails is independent. It doesn't affect task data or trigger network calls. Using useState here avoids unnecessary reducer complexity.
useMemo for filtering: Filtering is a pure computation. Deriving it inline or with useMemo prevents the render-cycle trap that occurs when developers push derived values into useEffect.
useEffect with AbortController: Network requests are external side effects. The effect synchronizes with the API, handles loading/error states via dispatch, and cleans up pending requests on unmount or projectId change. This prevents race conditions and memory leaks.
useCallback for event handlers: Wrapping handleStatusChange ensures child list items don't re-render unnecessarily when parent state updates. This is a production performance baseline, not an optimization afterthought.
Pitfall Guide
1. The Derived State Trap
Explanation: Using useEffect to compute values that already exist in props or state. This triggers an extra render cycle: compute â setState â re-render â compute again.
Fix: Compute inline or use useMemo. Only use useEffect when synchronizing with external systems.
2. The Coordination Tax
Explanation: Multiple useState calls that must update together. Handlers become fragile, and missing one update causes UI desync.
Fix: Migrate to useReducer. Group related state into a single object and define explicit action types for every transition.
3. The Stale Closure Loop
Explanation: Event handlers or effects capturing outdated state/props due to missing dependencies or improper functional updates.
Fix: Use functional state updates (setState(prev => ...)) when new state depends on previous. For effects, include all external dependencies in the array, or refactor to use refs for mutable values that shouldn't trigger re-renders.
4. The Effect Cascade
Explanation: An effect updates state, which triggers another effect, creating an infinite loop or unpredictable execution order.
Fix: Consolidate logic into a single effect or reducer. If multiple side effects must run, sequence them explicitly or lift shared logic into a custom hook that manages its own lifecycle.
5. The Reducer Overkill
Explanation: Using useReducer for simple, independent values like a single boolean toggle or a string input.
Fix: Reserve useReducer for state machines with 3+ related variables or explicit transition rules. Simple independent state belongs in useState.
6. The Missing Cleanup
Explanation: Subscriptions, timers, or event listeners attached in useEffect without a corresponding cleanup function. Causes memory leaks and duplicate handlers.
Fix: Always return a cleanup function from useEffect. For fetch requests, use AbortController. For intervals, use clearInterval. For DOM events, use removeEventListener.
7. The Dependency Array Illusion
Explanation: Assuming an empty dependency array [] means "run once". In React 18+ Strict Mode, effects run twice in development to surface cleanup issues. Relying on [] for initialization logic breaks in production.
Fix: Use useEffect for synchronization, not initialization. For one-time setup, use refs with a flag or leverage React 19's use hooks for data fetching. Always design effects to be idempotent.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single toggle or input field | useState | Minimal overhead, straightforward lifecycle | Low |
| Multi-step form or workflow with validation | useReducer | Explicit transitions, testable logic, prevents partial updates | Medium (initial setup) / Low (long-term) |
| Syncing with browser APIs, timers, or network | useEffect | Designed for external synchronization, supports cleanup | Medium (requires careful dependency management) |
| Filtering, sorting, or formatting existing data | Inline / useMemo | Zero extra renders, deterministic, easy to test | Low |
| Sharing state across deeply nested components | Context + useReducer or Zustand/Redux | Avoids prop drilling, centralizes state updates | High (architecture shift) / Low (maintenance) |
Configuration Template
Copy this structure for any new component requiring state management. It enforces separation of concerns and production-ready patterns.
import { useState, useReducer, useEffect, useMemo, useCallback } from 'react';
// 1. Define types
interface ComponentState { /* ... */ }
type ComponentAction = { type: 'ACTION_NAME'; payload?: any };
// 2. Pure reducer
function componentReducer(state: ComponentState, action: ComponentAction): ComponentState {
// Implement transitions
return state;
}
// 3. Component skeleton
export function Component({ id }: { id: string }) {
const [state, dispatch] = useReducer(componentReducer, { /* initial */ });
const [uiState, setUiState] = useState(false);
// Derived data
const derived = useMemo(() => { /* compute */ }, [state]);
// External sync
useEffect(() => {
const controller = new AbortController();
// fetch/subscribe logic
return () => controller.abort();
}, [id]);
// Stable handlers
const handleAction = useCallback((payload: any) => {
dispatch({ type: 'ACTION_NAME', payload });
}, []);
return <div>{/* UI */}</div>;
}
Quick Start Guide
- Audit your component: List every piece of state. Tag each as
independent, coordinated, or derived.
- Group coordinated state: If 2+ variables change together or depend on each other, create a
useReducer with explicit action types.
- Strip computation from effects: Find every
useEffect that calls setState with a computed value. Move it inline or to useMemo.
- Add cleanup boundaries: Ensure every
useEffect that touches external systems returns a cleanup function. Use AbortController for network calls.
- Validate with profiling: Open React DevTools, enable "Highlight updates", and interact with the component. Verify that only necessary parts re-render. If unrelated UI flashes, refactor handler stability or memoization.