How to Optimize useReducer in React
The useReducer hook is a powerful tool for managing
complex state in React, but poor implementation can lead to unnecessary
re-renders and performance bottlenecks. This article provides a
straightforward guide on how to optimize useReducer in
React, focusing on techniques like defining reducers outside the
component, implementing lazy initialization, splitting state and
dispatch contexts, and preventing redundant state updates.
1. Define the Reducer Function Outside the Component
One of the simplest yet most overlooked optimizations is the placement of the reducer function.
If you define the reducer function inside your React component, it is
recreated on every single render. This consumes unnecessary memory and
can cause optimization tools like React.memo to fail if the
reducer is passed down as a prop.
// Avoid this:
function MyComponent() {
const reducer = (state, action) => { ... };
const [state, dispatch] = useReducer(reducer, initialState);
}
// Do this:
const reducer = (state, action) => { ... };
function MyComponent() {
const [state, dispatch] = useReducer(reducer, initialState);
}By declaring the reducer outside the component scope, the function is created only once when the module loads.
2. Use Lazy Initialization for Expensive Initial State
If your initial state requires a heavy calculation (such as reading
from localStorage or filtering a massive array), do not
pass it directly as the second argument. Doing so will run the expensive
calculation on every render, even though React only uses the initial
state on the first mount.
Instead, use the third argument of useReducer (the
init function) to calculate the state lazily.
// Expensive calculation helper
const initializeState = (initialCount) => {
return { count: initialCount + performHeavyComputation() };
};
function MyComponent({ initialCount }) {
// The initializeState function only runs once on mount
const [state, dispatch] = useReducer(reducer, initialCount, initializeState);
}3. Prevent Unnecessary Re-renders with Split Contexts
When passing state and dispatch down to deeply nested child
components, wrapping them in a single Context provider can cause
performance issues. Any update to the state will force all components
consuming that context to re-render, even if they only need the
dispatch function.
To optimize this, split your context into two: one for state and one
for dispatch. Since React guarantees that the dispatch
function reference never changes, components consuming the dispatch-only
context will never re-render when the state changes.
const StateContext = React.createContext();
const DispatchContext = React.createContext();
function StateProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}4. Bail Out of State Updates Early
React determines whether to re-render a component by using the
Object.is comparison algorithm on the state. If your
reducer returns a new object reference on an action that didn’t actually
change any data, React will still trigger a re-render.
Always check if the incoming action requires a change. If it does
not, return the existing state object directly to bail out
of the render phase.
const reducer = (state, action) => {
switch (action.type) {
case 'SET_ACTIVE':
// If the value is already active, return the unchanged state reference
if (state.isActive === true) {
return state;
}
return { ...state, isActive: true };
default:
return state;
}
};