useState, useReducer and the mental model of state in React
On this page
State in React is a snapshot of one render, not a variable you update and read back. Most surprises with useState come from treating it like a variable. This page covers how useState behaves, how to shape state, and when useReducer is the better fit.
State is a snapshot
useState gives you a value and a setter:
const [count, setCount] = useState(0);count is fixed for the whole render. Calling setCount asks React for a new render with a new snapshot. The current render's count never changes.
That explains this result:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log(count); // still 0
}
return <button onClick={handleClick}>{count}</button>;
}One click takes count to 1, not 3. All three calls read the same snapshot where count is 0, and the console.log reads it too.
When the next state depends on the previous one, pass an updater function:
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);React queues the three functions and applies them in order, so count reaches 3. Use the function form whenever the new state depends on the old state.
The same snapshot model explains a stale value inside a useEffect closure, a console.log that shows the old value, and an update that seems not to apply.
React batches updates
Several setter calls in one event handler produce one render, not several. Since React 18 this also applies inside async functions and setTimeout.
function handleReset() {
setName("");
setEmail(""); // one render, not two
}You can call several setters together without a performance cost.
Lazy initial values
React uses the initial value only on the first render and ignores it after that. An expression passed directly still runs on every render:
// parseData() runs on every render, but the result is used once
const [data, setData] = useState(parseData(rawInput));
// parseData() runs once
const [data, setData] = useState(() => parseData(rawInput));Passing a function matters when the initial calculation is expensive. For a literal like 0, don't bother.
How to shape state
State that always changes together belongs in one object:
// Two values that always move in sync
const [lat, setLat] = useState(0);
const [lng, setLng] = useState(0);
// One object
const [position, setPosition] = useState({ lat: 0, lng: 0 });State that changes independently should stay separate. Grouping too much makes every update a spread, and forgetting the spread drops a key:
setPosition({ lat: newLat }); // lng is now gone
setPosition((prev) => ({ ...prev, lat: newLat }));If you write the spread on every update, the state may be better split.
Derive values instead of storing them
Don't store a value you can compute from existing state or props:
// Has to be kept in sync by hand
const [fullName, setFullName] = useState(`${firstName} ${lastName}`);
// Always correct
const fullName = `${firstName} ${lastName}`;The stored version needs updating every time firstName or lastName changes, which is a bug waiting to happen. The same goes for filtered lists, totals and formatted strings. State is for values React can't compute from what it already has.
When to switch to useReducer
useState starts to fight you when a component has several related useState calls and the update logic is spread across many handlers. A bug then means tracing which setter fired in what order. That is the point to use useReducer.
useReducer holds state like useState, with a different interface. You dispatch an action, and a reducer function decides the new state:
const [state, dispatch] = useReducer(reducer, initialState);
function reducer(state: State, action: Action): State {
// return the new state based on action.type
}The reducer must be a pure function. All the transition logic lives in one place, and the component only describes what happened.
A form with useReducer
A controlled form with several fields, an error and a loading flag is a good fit. Type the actions as a discriminated union:
type FormState = {
name: string;
email: string;
error: string | null;
loading: boolean;
};
type FormAction =
| { type: "set_name"; value: string }
| { type: "set_email"; value: string }
| { type: "submit_start" }
| { type: "submit_success" }
| { type: "submit_error"; message: string };
const initialState: FormState = {
name: "",
email: "",
error: null,
loading: false,
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "set_name":
return { ...state, name: action.value };
case "set_email":
return { ...state, email: action.value };
case "submit_start":
return { ...state, loading: true, error: null };
case "submit_success":
return { ...state, loading: false };
case "submit_error":
return { ...state, loading: false, error: action.message };
default:
return state;
}
}The component then names each change:
function ContactForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
dispatch({ type: "submit_start" });
try {
await submitForm({ name: state.name, email: state.email });
dispatch({ type: "submit_success" });
} catch {
dispatch({ type: "submit_error", message: "Something went wrong." });
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={state.name}
onChange={(e) => dispatch({ type: "set_name", value: e.target.value })}
/>
<input
value={state.email}
onChange={(e) => dispatch({ type: "set_email", value: e.target.value })}
/>
{state.error && <p>{state.error}</p>}
<button disabled={state.loading}>Submit</button>
</form>
);
}Reading dispatch({ type: "submit_start" }) tells you what happened without opening the reducer.
The union also lets TypeScript check each case. Inside case "submit_error", action.message is typed, and an action type the union doesn't list is a compile error. If you want shorter dispatch calls, add small action creators:
const actions = {
submitError: (message: string): FormAction => ({ type: "submit_error", message }),
};
dispatch(actions.submitError("Something went wrong."));Transitions that touch several fields
A reducer sees the whole current state, so one action can update several fields at once:
case "toggle_active":
return {
...state,
isActive: !state.isActive,
error: null,
lastModified: new Date().toISOString(),
};The useState version of that is a long updater function repeated in every handler that needs it.
Testing a reducer
The reducer is a plain function, so you can test each transition without rendering anything:
it("clears error on submit start", () => {
const state = { ...initialState, error: "previous error" };
const result = formReducer(state, { type: "submit_start" });
expect(result.error).toBeNull();
expect(result.loading).toBe(true);
});When to stay with useState
useReducer adds a layer of indirection, which a toggle doesn't need:
const [isOpen, setIsOpen] = useState(false);Stay with useState when the update fits in one line. Switch to useReducer when more than two or three related values update together. It also helps when the next state depends on more than the previous value, or when you want to test the transitions on their own.
When state has to be shared between components, see sharing state in React without Redux.