useState
is good for simple state which gets mutated in simple manner; but if we’ve a state which involves objects/arrays and they get mutated in different manner; in such cases useReducer
is preferred over useState
.
Following is the signature of useReducer
:
const [state, dispatch] = useReducer(reducer, initialArg, init?)
It takes reducer function, initial state, and init (initializer function — optional) as arguments. It returns the current state variable and a dispatch
function, using which we can update the state.
A reducer function specifies how the state should update. It should a pure function, and take state and action as arguments, and should return the next state. The initial value of the state can be of any type and also can be calculated by the
init
argument. 🚨 In Strict Mode (development), React will call our initializer function twice.
Here is a simple usage of useReducer
:
import { Reducer, useReducer } from "react";
// Basic usage of useReducer
type State = {
count: number;
};
type Action = {
type: "increment" | "decrement";
payload: number;
};
const reducer: Reducer<State, Action> = (state, action) => {
switch (action.type) {
case "increment":
return { count: state.count + action.payload };
case "decrement":
return { count: state.count - action.payload };
default:
throw new Error("Invalid action type");
}
};
const initialCount = 9;
export default function App(): JSX.Element {
const [state, dispatch] = useReducer(
reducer,
initialCount,
function (initialState) {
// In Strict Mode, React will call your reducer and initializer twice
return { count: initialState };
}
);
function increment() {
dispatch({ type: "increment", payload: 1 });
}
function decrement() {
dispatch({ type: "decrement", payload: 1 });
}
return (
<section>
<h1>Count: {state.count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</section>
);
}
useReducer
makes it easier to test our update logic too since its a pure function, and that why it should be preferred in cases where our state and its setter is spread out in our app.
Using immer
Since state is read-only, we can’t directly update objects/ arrays, we’ve to pass updated state. Updating all of this state can become cumbersome, especially if we’ve nested objects and array.
To solve this we can use use-immer
.
import { useImmerReducer } from "use-immer";
// Using useImmerReducer
type State = {
tasks: { title: string; done: boolean }[];
};
type Action =
| { type: "add"; payload: string }
| { type: "toggle"; payload: number };
export default function App(): JSX.Element {
const [state, dispatch] = useImmerReducer<State, Action>(
(draft, action) => {
switch (action.type) {
case "add":
draft.tasks.push({ title: action.payload, done: false });
break;
case "toggle": {
const task = draft.tasks[action.payload];
task.done = !task.done;
break;
}
default:
throw new Error("Invalid action type");
}
},
{ tasks: [] }
);
function toggleTask(index: number): void {
dispatch({ type: "toggle", payload: index });
}
function addTask(): void {
const title = prompt("Task title");
if (title) {
dispatch({ type: "add", payload: title });
}
}
return (
<section>
<h3>Tasks</h3>
<ul>
{state.tasks.map((task, index) => (
<li key={index}>
<label>
<input
type="checkbox"
checked={task.done}
onChange={() => toggleTask(index)}
/>
{task.title}
</label>
</li>
))}
</ul>
<button onClick={addTask}>Add Task</button>
</section>
);
}
Just like useState
, useReducer
also has following cases:
- The dispatch function only updates the state variable for the next render. If we read the state variable after calling the dispatch function, we’ll still get the old value that was on the screen before our call.
- If the new value we provide is identical to the current state, as determined by an
Object.is
comparison, React will skip re-rendering the component and its children. This is an optimization. React may still need to call your component before ignoring the result, but it shouldn’t affect your code. - React batches state updates. It updates the screen after all the event handlers have run and have called their set functions. This prevents multiple re-renders during a single event. In the rare case that we need to force React to update the screen earlier, for example to access the DOM, you can use
flushSync
. Avoid this pattern.