Manage complex multi-step form state with useReducer - explicit actions like SET_FIELD, NEXT_STEP, and VALIDATE make state transitions predictable, testable, and easy to debug without reaching for external libraries.
When to reach for this: When your form has 3+ steps, cross-step validation, or state transitions complex enough that juggling multiple useState calls becomes error-prone.
Stale state in async callbacks - If you read state inside a setTimeout or await, you get the value at dispatch time, not the latest. Fix: Use useRef to mirror current state, or dispatch an action from the async callback instead of reading state.
Object spread creates shallow copies only - Nested objects (like fields inside state) must also be spread: { ...state, fields: { ...state.fields, [name]: value } }. Fix: Always spread at every nesting level you're modifying, or use Immer's produce.
Forgetting to clear errors on field change - Users fix the error but the message stays. Fix: Clear the specific field's error inside SET_FIELD, as shown in the working example.
Reducer must be pure - No API calls, no localStorage reads, no Date.now() inside the reducer. Fix: Perform side effects in the component or event handler, then dispatch the result.
Validation runs against stale state after dispatch - dispatch doesn't update state synchronously. Calling dispatch({ type: "SET_FIELD" }) then reading state.fields gives the old value. Fix: Validate using the value you're about to dispatch, not the current state.
Large reducers become hard to read - A 200-line switch statement is worse than 8 useState calls. Fix: Extract case handlers into named functions: case "SET_FIELD": return handleSetField(state, action);.
Missing default case in switch - TypeScript won't warn if you miss a case unless you add exhaustive checking. Fix: Add default: { const _exhaustive: never = action; return state; } to catch unhandled actions at compile time.