useReducer is where useState stops being readable

Four useState calls whose setters are always called together are a state machine written as four independent variables, and every transition has to remember all four.

function reducer(state, action) {
  switch (action.type) {
    case 'load':    return { ...state, loading: true, error: null };
    case 'loaded':  return { loading: false, error: null, data: action.data };
    case 'failed':  return { loading: false, error: action.error, data: null };
    default:        throw new Error(`unknown action ${action.type}`);
  }
}

const [state, dispatch] = useReducer(reducer, initial);

The transitions become named and enumerable, which means an invalid combination — loading and error simultaneously — stops being expressible. The reducer is a pure function, so it is testable with no React at all, which is the practical benefit people notice second and value most. dispatch is stable across renders, so passing it to a child needs no useCallback, which is a small and real simplification.