React: useReducer vs useState
For simple components, useState is perfect. But as a component grows and state interactions become highly complex, multiple useState calls can quickly devolve into spaghetti code.
When should you use
useReducerinstead ofuseState?
Interviewers ask this to see if you understand state architecture. Can you recognize when imperative state updates are causing bugs, and do you know how to centralize that logic using the Redux pattern natively in React?
1. The Problem with Multiple useState Calls
Imagine a data fetching component. It needs to track loading status, errors, and the actual data payload.
function DataFetcher() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetch('/api/data');
const json = await res.json();
setData(json);
setIsLoading(false); // Bug-prone: Easy to forget this!
} catch (err) {
setError(err.message);
setIsLoading(false);
}
};
}The Issue: The isLoading, data, and error states are deeply intertwined. You shouldn't be able to have isLoading: true while simultaneously holding a new data payload. Managing them as separate variables requires the developer to remember to perfectly sequence multiple state updates manually.
2. Enter useReducer
useReducer solves this by consolidating interrelated state into a single object and restricting mutations to predefined Actions.
Instead of telling React how to update the state imperatively (setIsLoading(false)), you dispatch an Action telling React what happened (dispatch({ type: 'FETCH_SUCCESS' })). The central Reducer function handles the exact logic.
Step 1: Define the Reducer
The reducer is a pure function that takes the currentState and an action, and returns the entirely new state object.
// 1. The Reducer Function (Usually placed outside the component)
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { data: null, isLoading: true, error: null };
case 'FETCH_SUCCESS':
// Atomically update both data and loading status!
return { data: action.payload, isLoading: false, error: null };
case 'FETCH_ERROR':
return { data: null, isLoading: false, error: action.payload };
default:
return state;
}
}Step 2: Use the Hook in the Component
function DataFetcher() {
// 2. Initialize the hook
const [state, dispatch] = useReducer(fetchReducer, {
data: null,
isLoading: false,
error: null
});
const fetchData = async () => {
// 3. Dispatch actions!
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch('/api/data');
const json = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: json });
} catch (err) {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
};
// Render based on the centralized state object
if (state.isLoading) return <p>Loading...</p>;
if (state.error) return <p>Error: {state.error}</p>;
return <div>{JSON.stringify(state.data)}</div>;
}3. When should you switch?
Use useReducer when:
- The next state depends heavily on the previous state. (Instead of messy
setCount(prev => prev + 1)callbacks, a reducer inherently has access to the previous state). - You have complex, interrelated state objects. (Updating one field usually requires updating another, like clearing errors when starting a new fetch).
- You want to decouple logic from the component. (The reducer function can be exported and unit-tested in complete isolation).
Senior-Level Interview Answer
While
useStateis sufficient for primitive, independent state values, it becomes highly brittle when managing complex, interrelated state objects. As business logic grows, scattering imperative state setters throughout event handlers leads to impossible state combinations and race conditions.useReducersolves this by introducing a predictable, unidirectional data flow pattern (borrowed from Redux). By decoupling the state transition logic into a pure, testable reducer function, components no longer dictate how state updates, but merely dispatch intent actions detailing what occurred. This architecture guarantees state atomicity and significantly reduces cognitive load in complex components like form wizards or data fetchers.
Common Interview Mistakes
❌ Mutating state directly inside the reducer
Just like Redux, the reducer function must be pure. You cannot mutate the incoming state directly (state.isLoading = true; return state;). You must always return a brand-new object.
// Correct
return { ...state, isLoading: true };❌ Over-engineering simple components
If an interviewer asks you to build a simple toggle button, don't use useReducer. Reaching for complex boilerplate for a boolean isOpen state shows poor architectural judgement. useState is still the king for simple UI flags.
