START_TASK' }
| { type: 'UPDATE_PROGRESS'; payload: number }
| { type: 'PAUSE_TASK' }
| { type: 'RESUME_TASK' }
| { type: 'COMPLETE_TASK' }
| { type: 'FAIL_TASK'; payload: string }
| { type: 'RESET_TASK' };
### Step 2: Implement the Pure Reducer
The reducer function becomes the single source of truth for state transitions. It remains pure, deterministic, and easily unit-testable.
```typescript
// reducer.ts
export const taskReducer = (state: TaskState, action: TaskAction): TaskState => {
switch (action.type) {
case 'START_TASK':
return { ...state, status: 'pending', progress: 0, errorMessage: null };
case 'UPDATE_PROGRESS':
return { ...state, progress: Math.min(100, Math.max(0, action.payload)) };
case 'PAUSE_TASK':
return state.status === 'pending' ? { ...state, status: 'paused' } : state;
case 'RESUME_TASK':
return state.status === 'paused' ? { ...state, status: 'pending' } : state;
case 'COMPLETE_TASK':
return { ...state, status: 'completed', progress: 100 };
case 'FAIL_TASK':
return {
...state,
status: 'failed',
errorMessage: action.payload,
retryCount: state.retryCount + 1,
};
case 'RESET_TASK':
return { status: 'idle', progress: 0, errorMessage: null, retryCount: 0 };
default:
return state;
}
};
Step 3: Build the Orchestrator Hook
This hook demonstrates the architectural synergy between useReducer and useRef. The reducer manages UI-reactive state, while refs handle non-reactive concerns: interval IDs, abort controllers, and previous state snapshots for comparison.
// useTaskOrchestrator.ts
import { useReducer, useRef, useCallback, useEffect } from 'react';
import { taskReducer, TaskState, TaskAction } from './types';
const INITIAL_STATE: TaskState = {
status: 'idle',
progress: 0,
errorMessage: null,
retryCount: 0,
};
export function useTaskOrchestrator() {
const [state, dispatch] = useReducer(taskReducer, INITIAL_STATE);
// Refs for non-reactive, mutable concerns
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const previousStatusRef = useRef<TaskState['status']>(INITIAL_STATE.status);
// Track status changes without triggering re-renders
useEffect(() => {
previousStatusRef.current = state.status;
}, [state.status]);
const executeTask = useCallback(() => {
if (state.status !== 'idle' && state.status !== 'failed') return;
dispatch({ type: 'START_TASK' });
abortRef.current = new AbortController();
intervalRef.current = setInterval(() => {
if (abortRef.current?.signal.aborted) {
clearInterval(intervalRef.current!);
return;
}
dispatch((prev: TaskAction) => ({
type: 'UPDATE_PROGRESS',
payload: Math.random() * 15 + 5,
}));
}, 800);
// Simulate async completion
setTimeout(() => {
if (!abortRef.current?.signal.aborted) {
dispatch({ type: 'COMPLETE_TASK' });
clearInterval(intervalRef.current!);
}
}, 5000);
}, [state.status]);
const cancelTask = useCallback(() => {
abortRef.current?.abort();
clearInterval(intervalRef.current!);
dispatch({ type: 'FAIL_TASK', payload: 'Task cancelled by user' });
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
clearInterval(intervalRef.current!);
};
}, []);
return { state, dispatch, executeTask, cancelTask };
}
Architecture Decisions and Rationale
- State Machine over Conditional Logic: Using explicit
TaskStatus values prevents impossible states (e.g., pending and completed simultaneously). The reducer enforces valid transitions, eliminating race conditions that plague scattered setState calls.
- Ref Partitioning:
intervalRef and abortRef are deliberately excluded from React's reactive system. Storing them in state would trigger unnecessary re-renders every time the interval ID changes or the abort controller is recreated. Refs provide stable, mutable containers that survive re-renders without impacting the virtual DOM.
- Stable Dispatch Reference:
useReducer guarantees that dispatch maintains referential equality across renders. This enables safe dependency arrays in useEffect and useCallback, preventing infinite loops and stale closure bugs.
- Cleanup Isolation: The
useEffect cleanup function explicitly terminates intervals and aborts pending operations. This pattern prevents memory leaks and state updates on unmounted components, a common production failure point.
Pitfall Guide
1. Mutating .current During the Render Phase
Explanation: Writing to ref.current directly inside the component body violates React's rendering contract. React may invoke the component multiple times in development (Strict Mode) or during concurrent rendering, causing unpredictable side effects.
Fix: Restrict .current mutations to event handlers, useEffect, or initialization. Use refs only to store values that are read during render or modified imperatively outside the render cycle.
2. Treating Refs as Reactive State
Explanation: Developers frequently store UI-dependent values in refs to avoid re-renders, then wonder why the interface doesn't update. Refs bypass React's reconciliation entirely.
Fix: If a value change should trigger a UI update, it belongs in useState or useReducer. Use refs exclusively for values that are incidental to rendering (timers, DOM nodes, previous snapshots, external library instances).
3. Over-Dispatching in useReducer
Explanation: Dispatching multiple actions in rapid succession within a single event handler can cause intermediate renders if React's automatic batching doesn't catch them, or create confusing state snapshots in dev tools.
Fix: Consolidate related updates into a single action type with a composite payload. Alternatively, leverage React 18's automatic batching by ensuring all dispatches occur within the same synchronous execution context.
4. Ignoring Action Type Exhaustiveness
Explanation: TypeScript's type narrowing fails when reducer switch statements lack a default case or when action types aren't strictly unioned. This leads to runtime errors when unknown actions slip through.
Fix: Always include a default: return state clause. Use TypeScript's never type in the default case to catch missing action types at compile time: const _exhaustiveCheck: never = action;.
5. Stale Closures in Ref Callbacks
Explanation: When refs are accessed inside useCallback or useEffect without proper dependency management, they capture outdated values. This is especially common when reading ref.current inside async callbacks.
Fix: Read ref.current at the exact moment of execution, not during closure creation. For async operations, pass the current value as an argument or use a ref specifically designed to hold the latest callback/function.
6. Forgetting Cleanup in Effect-Ref Hybrids
Explanation: Components that initialize external resources (WebSockets, intervals, subscriptions) via refs often omit cleanup logic, leading to memory leaks and duplicate event listeners on re-mounts.
Fix: Always pair ref-initialized resources with a useEffect cleanup function. Return the teardown logic from the effect, and ensure it safely handles null or already-cleared references.
7. Using useRef for DOM Measurements Without ResizeObserver
Explanation: Reading offsetWidth or getBoundingClientRect() once via a ref captures a static snapshot. If the layout changes due to window resize or sibling updates, the measurement becomes stale.
Fix: Combine useRef with ResizeObserver or useLayoutEffect to recalculate dimensions when the DOM mutates. Cache measurements in refs only when they're expensive to compute and don't require reactive updates.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple toggle or counter | useState | Minimal overhead, straightforward API | Low |
| Multi-field form with validation | useReducer | Centralizes validation logic, prevents partial updates | Medium |
| Real-time progress tracker | useReducer + useRef | Reducer handles UI state, ref manages interval/abort | Medium |
| Deeply nested component updates | useReducer + useContext | Stable dispatch prevents prop drilling and re-renders | High (initial setup) |
| DOM focus/scroll measurement | useRef | Direct imperative access without state overhead | Low |
| External library instance (e.g., chart, map) | useRef | Persists across renders, avoids re-initialization | Low |
Configuration Template
Copy this template into your project to establish a production-ready hook pattern. It includes TypeScript safety, cleanup guards, and dev-mode warnings.
// useOrchestrator.ts
import { useReducer, useRef, useCallback, useEffect } from 'react';
type State = { status: string; data: any };
type Action = { type: string; payload?: any };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'SET_STATUS':
return { ...state, status: action.payload };
case 'SET_DATA':
return { ...state, data: action.payload };
case 'RESET':
return { status: 'idle', data: null };
default:
return state;
}
};
export function useOrchestrator(initialState: State) {
const [state, dispatch] = useReducer(reducer, initialState);
const cleanupRef = useRef<(() => void) | null>(null);
const registerCleanup = useCallback((fn: () => void) => {
cleanupRef.current = fn;
}, []);
useEffect(() => {
return () => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}, []);
return { state, dispatch, registerCleanup };
}
Quick Start Guide
- Identify the boundary: Locate a component where state updates depend on previous values or where multiple
useState calls interact. Extract the update logic into a pure reducer function.
- Initialize the hook: Replace
useState declarations with useReducer(yourReducer, initialState). Destructure state and dispatch.
- Migrate imperative concerns: Move interval IDs, abort controllers, or DOM references into
useRef. Ensure they're only mutated in event handlers or effects.
- Wire the UI: Replace direct state setters with
dispatch({ type: 'ACTION_NAME', payload: value }). Verify that dispatch is stable in dependency arrays.
- Validate and profile: Run React DevTools Profiler to confirm reduced render cycles. Write unit tests for the reducer to guarantee transition correctness before deploying to production.