The Ultimate Guide to useReducer: Best Practices and Pitfalls
This post walks through the useReducer hook in React and how it simplifies managing complex state. We’ll cover setting up a reducer, dispatching state updates, and pairing the hook with useContext for global state. The goal is for you to come away with a clear sense of when reaching for a reducer pays off over plain useState.
1. Introduction
Once a component sprouts a handful of event handlers all poking at the same state, things get tangled fast. The fix is to pull that state-update logic into a single centralized function — a reducer. useReducer is React’s answer to that, an alternative to useState for more complex state logic. It gives you something close to what 3rd-party tools like Redux offer, with far less boilerplate.
💡
useReduceris an alternative touseStatefor managing more complex state logic.
You call useReducer at the top level of the component, passing it a reducer function. Convention is to define that reducer outside the component — usually in its own file if it grows past a few cases. Here’s the skeleton:
import { useReducer } from 'react'
function reducer(state, action) {
// Define how the state should change based on the action
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { theme: "dark", language: "eng" })
// ...2. Reference
const [state, dispatch] = useReducer(reducer, initialArg, init?)reducer: The reducer function that specifies how the state gets updated. It must be pure, should take the state and action as arguments, and should return the next state. State and action can be of any types.initialArg: The value from which the initial state is calculated. It can be a value of any type. How the initial state is calculated from it depends on the nextinitargument.init?: The initializer function that should return the initial state. If it’s not specified, the initial state is set toinitialArg. Otherwise, the initial state is set to the result of callinginit(initialArg).
3. Innerworkings of useReducer
When initializing state management with useReducer, you provide a reducer function and an initial state value as arguments. This call returns an array (a tuple) containing two elements: the state object and a dispatch function.
const [state, dispatch] = useReducer(reducer, { theme: "dark", language: "eng" })To update the state, you use the dispatch function, which internally invokes the reducer function. The reducer function contains the state logic: it takes two arguments, the current state and the action object, and returns the new state.
function reducer(state, action) {
// ...
}Keep in mind that directly invoking the reducer function does NOT update the state. Instead, you should use the dispatch function to update the state.
💡 Invoking the reducer function directly does not update state. You should invoke the dispatch function to update state.
The parameter supplied to the dispatch function is called an action. This action object can have any shape, but the convention is to include a string property called “type” to specify what happened. You can also include any additional information needed as separate properties. For example, an action to update the theme might look like this:
dispatch(
// "action" object:
{
type: "themechange", // what happened
selection: "dark" // any additional info
// ...
}
)Remarks on State Complexity
With useReducer you’ll usually be managing a state object, not a single value — that’s the shape the hook is built for. If you only need a single value, reach for useState instead.
The hook earns its keep when state is genuinely complex.
const [count, dispatch] = useReducer(reducer, 0) // probably not a good idea
const [state, dispatch] = useReducer(reducer, { count: 0 }) // better approach4. Usage
Let’s build a small example that manages theme and language selections with a reducer.
Step 1: Define the reducer function that will handle the state update logic. For now, it can be a placeholder. The reducer function should be defined outside of any components. It takes two arguments: the current state and an action object, and returns the updated state. React will set the state to the value returned by the reducer.
import { useReducer } from 'react'
function reducer(state, action)
// update state here based on the action
// then return the updated state
}
function MyComponent() {
// ...Step 2: Invoke useReducer at the top level of our component, providing the reducer function reference as well as the initial state.
import { useReducer } from 'react'\
function reducer(state, action)
// update state here based on the action
// then return the updated state
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { theme: "dark", language: "eng" })
// ...At this point, the boilerplate for useReducer is ready. Let’s implement the component so it has a “switch theme” button which the user can click to switch between dark and light themes.
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { theme: 'dark' })
const toggleTheme = () => {
dispatch({ type: 'SWITCH_THEME' }) // invoke dispatch function to update state
}
return (
<div style={{ background: state.theme === 'dark' ? '#333' : '#ccc', color: state.theme === 'dark' ? '#fff' : '#000' }}>
<h1>Current Theme: {state.theme}</h1>
<button onClick={toggleTheme}>Switch Theme</button>
</div>
)
}Step 3: Now, implement the reducer function to handle the state update logic based on the action.
function reducer(state, action) {
switch (action.type) {
case 'SWITCH_THEME':
return { ...state, theme: state.theme === 'dark' ? 'light' : 'dark' }
default:
return state
}
}In this example, we’ve handled the SWITCH_THEME action type. To extend this example, you can add more action types. For instance, to support language changes, modify the reducer function as follows:
function reducer(state, action) {
switch (action.type) {
case 'SWITCH_THEME':
return { ...state, theme: state.theme === 'dark' ? 'light' : 'dark' }
case 'CHANGE_LANGUAGE':
return { ...state, language: action.langChoice }
default:
return state
}
}This way, you can add any number of action types, making state management easier as complexity grows. Here’s the final code:
import { useReducer } from 'react'
function reducer(state, action) {
switch (action.type) {
case 'SWITCH_THEME':
return { ...state, theme: state.theme === 'dark' ? 'light' : 'dark' }
case 'CHANGE_LANGUAGE':
return { ...state, language: action.langChoice }
default:
return state
}
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { theme: 'dark' })
const toggleTheme = () => {
dispatch({ type: 'SWITCH_THEME' }) // invoke dispatch function to update state
}
const changeLanguage = (newLanguage) => {
dispatch({ type: 'CHANGE_LANGUAGE', langChoice: newLanguage })
}
return (
<div style={{ background: state.theme === 'dark' ? '#333' : '#ccc', color: state.theme === 'dark' ? '#fff' : '#000' }}>
<h1>Current Theme: {state.theme}</h1>
<h2>Current Language: {state.language}</h2>
<button onClick={toggleTheme}>Switch Theme</button>
<button onClick={() => changeLanguage('eng')}>Change to English</button>
<button onClick={() => changeLanguage('es')}>Change to Spanish</button>
</div>
)
}5. Pitfalls
5.1. Immutable State Updates
Mutating the state object directly rather than returning a new state object is a common mistake made in the reducer function implementation. Reducers must be pure, therefore, you should always return a new state object from to ensure proper state updates.
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// ❌ DON'T MUTATE STATE LIKE THIS!
state.age = state.age + 1
return state
}
// ...Instead, return new a new object:
function reducer(state, action) {
switch (action.type) {
case 'incremented_age': {
// ✅ RETURNING A NEW OBJECT
return {
...state,
age: state.age + 1
};
}Regarding Accessing Next State
Calling dispatch does not update the state in the current component instance. For example:
function handleClick() {
console.log(state.age); // 27
dispatch({ type: 'incremented_age' });
console.log(state.age); // Still 27, but why ??!
}In the preceding code, even if the state does get correctly updated, you wouldn’t be able to see the update reflected in that running instance. If you need to access the updated state value, you can invoke the reducer function directly. This would be the only case where you might want to manually call the reducer function. Invoking the reducer function manually does not actually update the state, so it’s safe to do so.
function handleClick() {
console.log(state.age) // 27
dispatch({ type: 'incremented_age' }) // Request a re-render with 43
const nextState = reducer(state, action)
console.log(nextState.age) // 28
}5.2. Insufficient Action Type Handling
Always include a default case in the reducer function to handle unexpected actions. This helps to prevent unintended state changes and makes debugging easier.
function reducer(state, action) {
switch (action.type) {
case "SWITCH_THEME":
return { ...state, theme: state.theme === "dark" ? "light" : "dark" }
case "CHANGE_LANGUAGE":
return { ...state, language: action.langChoice }
default:
console.error("unexpected action type: " + action.type)
return state
}
}5.2.1. Always Return State From the Reducer
A common mistake is to not return state in every code flow in the reducer function. This will cause your state to become undefined, so make sure you always return state, even if you didn’t update it (as in the preceding example).
5.2.2. Regarding Component Updates
useReducer only re-renders the component when the state actually changes. If the reducer returns the state as is — for instance when a switch falls through to its default branch — the UI won’t update.
5.3. Inconsistent Action Structure
Having an inconsistent action object structure across the project can make the code more difficult to maintain. The convention is to include a string property called “type” to specify what happened, and to include any additional information needed as separate properties. For example:
dispatch({
type: "themechange", // what happened
selection: "dark" // any additional info
// ...
})5.4. Incorrect Event Handling
If you encounter an error stating “Too many re-renders. React limits the number of renders to prevent an infinite loop.”, chances are your code has triggered an infinite loop. This often happens if an event handler is incorrectly specified and continuously emits a dispatch, causing React to repeatedly re-render.
// ❌ Wrong: calls the handler during render
return <button onClick={handleClick()}>Click me</button>
// ✅ Correct: passes down the event handler
return <button onClick={handleClick}>Click me</button>
// ✅ Correct: passes down an inline function
return <button onClick={(e) => handleClick(e)}>Click me</button>5.5. Complex Reducer Logic
Writing overly complex or convoluted reducer functions can reduce the maintainability of the code. Therefore, you should keep the reducer function as simple and predictable as possible. Complex logic can make it harder to understand and debug. If the logic becomes too intricate, consider breaking it into smaller, more manageable functions.
The convention is to use switch statements inside reducers. For example:
function reducer(state, action) {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + 1 };
case "DECREMENT":
return { ...state, count: state.count - 1 };
case "RESET":
return { ...state, count: 0 };
default:
return state;
}
}If you’re not comfortable with switch statements, you can use if/else instead. Just make sure the code stays readable and that every branch returns a state (otherwise state ends up undefined).
6. Best Practices for Writing Reducers
- Place your reducers outside of any components, ideally into a separate file.
- Use
switchstatements in reducers instead ofifstatements. - Handle every possible action. The set of cases your reducer covers need to include “fallback” logic — a
defaultbranch in the switch, say — so unexpected action types don’t silently drop state. - Keep reducers pure. Reducer functions must be deterministic and pure functions. This is because reducers run during rendering (actions are queued until the next render), and any code that runs during rendering must be pure in React. This means that reducers should not send requests or perform any other side effects (operations that impact things outside the component). Keep in mind that in Strict Mode, React will call your reducer and initializer twice in order to help you find accidental impurities early on.
- Ensure each action describes a single user interaction, even if that leads to multiple changes in the data. For example, if user clicks “Reset” on a form with multiple fields that’s managed by a reducer, it’s more appropriate to dispatch a single “reset_form” action rather than dispatching five separate “set_field” actions. Remember that actions are supposed to reflect user interactions.
- Use descriptive action types. Action types should clearly reflect the associated user interaction.
7. Utilizing Initializer Function Argument of useReducer
React saves the initial state once, and ignores it on the following renders.
function createInitialTodoState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, createInitialTodoState(username));
// ...Although the result of createInitialState(username) is only used during the initial render, the function still gets executed on every render. This can cause performance issues if the function is computationally expensive.
To solve this problem, you can use the optional 3rd argument of useReducer, which takes an “initializer function” reference. When this 3rd argument is provided, React uses the 2nd argument and uses it as the parameter for the initializer function, invoking it to acquire the initial state.
function createInitialTodoState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, username, createInitialTodoState)
// ...Notice that you’re passing createInitialTodoState, which is the function itself, and not createInitialTodoState(), which is the result of calling it. This way, React will invoke the createInitialTodoState method with the username as an argument to calculate the initial state. If the initializer function does not need any arguments, you may pass null as the 2nd argument to useReducer.
8. useState vs. useReducer
When deciding between useState and useReducer, it’s important to weigh their respective advantages and drawbacks. Here is a comparison of the two:
| useState | useReducer | |
|---|---|---|
| Code Size | Generally requires less code upfront | While it requires more boilerplate initially, it can cut down on the code if many event handlers are being used. |
| Readability | Very easy to read when the state updates are simple. However, when they get more complex, they can become difficult to maintain. | Allows to maintain a cleaner separation between event handlers and the logic, achieving a more maintainable code if the state is complex. |
| Debugging | Diagnosing issues with useState can be challenging, especially when it’s not clear why the state ended up in a particular condition. | With useReducer, you can gain better visibility by logging each state transition along with the action that triggered it. This makes it easier to pinpoint issues. |
| Testability | Testing state updates managed by useState usually involves testing the entire component, which is not easy. | Since a reducer is a pure function independent of your component, you can export and test it in isolation. |
Generally, you should use useState for simple state management tasks, and only use useReducer as state complexity grows.
If you keep running into bugs from incorrect state updates, a reducer can help impose some structure. There’s no one-size-fits-all rule here. Mix and match the two hooks based on what the component actually needs.
Some developers prefer reducers, others don’t — taste plays a real role. You can switch between useState and useReducer as needed; they achieve the same outcome and the difference is maintainability.
9. Managing Global State
Global state across multiple components is a good fit for useReducer + useContext, usually wrapped in a small custom hook. The combo keeps the state logic in one place and lets any component in the tree read or update it.
Step 1: Define the Reducer
First, define the reducer function that will handle state updates. This function will process actions and return the new state based on the current state and the action dispatched.
// globalReducer.js
export function globalReducer(state, action) {
switch (action.type) {
case 'TOGGLE_THEME':
return { ...state, theme: state.theme === 'dark' ? 'light' : 'dark' }
case 'SET_LANGUAGE':
return { ...state, language: action.langChoice }
default:
return state
}
}Step 2: Create the Context
Next, create a context to provide the state and dispatch function to all components that need access to them. This context will be used to share the global state and dispatch function throughout the component tree.
// GlobalStateContext.js
import React, { createContext, useContext, useReducer } from 'react'
import { globalReducer } from './globalReducer'
const GlobalStateContext = createContext() // the global context
// global context provider
export function GlobalStateProvider({ children }) {
const [state, dispatch] = useReducer(globalReducer, { theme: 'dark', language: 'en' })
return (
<GlobalStateContext.Provider value={{ state, dispatch }}>
{children}
</GlobalStateContext.Provider>
)
}
// create a custom hook to use the context
export function useGlobalState() {
return useContext(GlobalStateContext)
}Step 3: Wrap Your Application with the Provider
Wrap your application with the GlobalStateProvider so that any component in the tree can access the global state and dispatch function.
// App.js
import React from 'react'
import { GlobalStateProvider } from './GlobalStateContext'
import MyComponent from './MyComponent'
function App() {
return (
<GlobalStateProvider>
<MyComponent />
</GlobalStateProvider>
)
}
export default AppStep 4: Access and Update Global State in Components
Use the custom useGlobalState hook to access the global state and dispatch function from within any component.
// MyComponent.js
import React from 'react'
import { useGlobalState } from './GlobalStateContext'
function MyComponent() {
const { state, dispatch } = useGlobalState()
const toggleTheme = () => {
dispatch({ type: 'TOGGLE_THEME' })
}
const setLanguage = (lang) => {
dispatch({ type: 'SET_LANGUAGE', langChoice: lang })
}
return (
<div>
<h1>Current Theme: {state.theme}</h1>
<button onClick={toggleTheme}>Switch Theme</button>
<h2>Current Language: {state.language}</h2>
<button onClick={() => setLanguage('en')}>English</button>
<button onClick={() => setLanguage('fr')}>French</button>
</div>
)
}
export default MyComponentTogether, useReducer and useContext give you a tidy way to manage global state without pulling in a heavier library. The state lives in one place and every component touches it the same way.
10. Bonus: Why Reducers Are Called “Reducers”
Reducers do help “reduce” the code inside your components, but the name actually comes from JavaScript’s reduce function on arrays.
The reduce function processes an array to produce a single accumulated value. For example:
const arr = [1, 1, 1]
arr.reduce(
(result, number) => result + number
) // will output "3"The function passed to reduce is called a “reducer.” It takes two arguments: the “accumulator” (or the “result so far”) and the “current item,” and returns the “next result.” This concept is similar to how React’s reducer function works, taking the current state and an action to return the new state, accumulating actions over time into state.