Understanding How Reducers Are Used In Redux

Master the fundamental patterns of predictable state management with Redux reducers, from core concepts to modern implementation strategies.

State management is one of the most challenging aspects of building modern web applications. As your application grows in complexity, tracking how and where state changes becomes increasingly difficult. Redux provides a predictable state management pattern that has become a cornerstone of React development, and at the heart of Redux lies the reducer--a pure function that transforms your application's state based on dispatched actions.

Whether you're building a simple todo list or a complex enterprise application, understanding reducers is essential for writing maintainable, debuggable code. This guide will walk you through everything you need to know about reducers, from the fundamental concepts to practical implementation patterns that you can apply to your projects immediately.

What Is a Reducer?

At its core, a reducer is a function that takes two arguments--your current state and an action--and returns a new state based on that action. Think of it as a ledger entry that records what happened and updates your records accordingly. The name comes from the Array.reduce method in JavaScript, which similarly takes a collection of values and reduces them to a single value through a reducer function.

Reducers are the heart of Redux's state management architecture. Every time you dispatch an action to the Redux store, that action gets passed to your reducer function along with the current state. The reducer then examines the action type and payload, determines what changes need to be made, and returns the new state. The fundamental reducer signature is remarkably simple: (state, action) => newState. This simplicity is deceptive, however, because the logic contained within that function can range from straightforward conditional updates to complex state transformations involving multiple data structures and nested objects.

How Reducers Fit Into Redux Architecture

Understanding reducers requires seeing them in the context of the larger Redux data flow. When a user interacts with your application--clicking a button, submitting a form, or triggering any other event--your code dispatches an action to the Redux store. This action is a plain JavaScript object that describes what happened, typically including a type field and any additional data needed to process the change.

The store then takes this action and passes it to your reducer function, along with the current state tree. The reducer's job is to read both the action and the current state, perform the necessary logic to update the state appropriately, and return the new state value. Once the reducer returns the new state, the Redux store updates its internal state and notifies any subscribed components that the state has changed.

This unidirectional data flow is what makes Redux so predictable. Every state change follows the same pattern: action dispatched, reducer processes action, new state produced, components re-render. There are no hidden side effects or mysterious state mutations happening behind the scenes.

The Three Core Rules of Reducers

Reducers must follow three fundamental rules that ensure your state management remains predictable and debuggable. These rules are not optional guidelines--they're essential principles that make Redux work as intended.

Rule 1: State Is Immutable

The most critical rule is that reducers must never mutate the existing state. Instead, they must return a new state object (or array, or whatever data structure you're using) with the necessary changes applied. This means you cannot use methods like Array.push or object property assignment to modify state directly. Instead, you must create copies of the data and modify those copies.

This immutability requirement might seem cumbersome at first, but it's essential for several reasons. First, it ensures that Redux can detect state changes--when the reducer returns a new object reference, Redux knows something changed. Second, it enables powerful debugging tools like Redux DevTools, which can track every state change and even allow you to time-travel through your application's history.

// INCORRECT - Mutates state directly
function todosReducer(state = [], action) {
 if (action.type === 'ADD_TODO') {
 state.push(action.payload); // WRONG! Mutates state
 }
 return state;
}

// CORRECT - Returns new state
function todosReducer(state = [], action) {
 if (action.type === 'ADD_TODO') {
 return [...state, action.payload]; // Returns new array
 }
 return state;
}

Rule 2: Pure Functions

Reducers must be pure functions, meaning they have no side effects and always return the same output for the same input. A pure function doesn't modify its arguments, doesn't call any non-pure functions (like Date.now or Math.random), and doesn't perform asynchronous operations or external API calls.

// INCORRECT - Not a pure function
function todosReducer(state = [], action) {
 const timestamp = Date.now(); // Side effect
 if (action.type === 'ADD_TODO') {
 return [...state, { ...action.payload, createdAt: timestamp }];
 }
 return state;
}

// CORRECT - Pure function
function todosReducer(state = [], action) {
 if (action.type === 'ADD_TODO') {
 return [...state, action.payload];
 }
 return state;
}

Rule 3: Only Calculate New State Based on Arguments

A reducer should only look at the state and action arguments to determine the new state. It should not read from closure variables, DOM elements, or any other external source. This rule ensures that the reducer's behavior is completely determined by its inputs, making it predictable and testable.

// INCORRECT - Depends on external variable
let currentUserId;

function todosReducer(state = [], action) {
 if (action.type === 'ADD_TODO') {
 return [...state, { ...action.payload, userId: currentUserId }];
 }
 return state;
}

// CORRECT - All data from arguments
function todosReducer(state = [], action) {
 if (action.type === 'ADD_TODO') {
 return [...state, action.payload];
 }
 return state;
}

Actions: The Payload of Intention

Actions are the bridge between user interactions and state changes in Redux. An action is a plain JavaScript object that describes what happened in your application. By convention, every action must have a type property--a string that identifies the action--and may optionally include a payload property containing additional data needed to process the action.

The action type should be a meaningful string that describes the event. Common conventions use uppercase with underscores, like "ADD_TODO" or "USER_LOGGED_IN". Using string constants for action types makes debugging easier, as these strings appear in Redux DevTools and console logs.

Actions can be created using action creators--functions that return action objects. This abstraction is useful when you need to perform additional logic before creating an action, such as generating IDs or validating data. Our JavaScript development services team regularly implements these patterns in production applications, ensuring clean separation between actions and state logic.

Comparing useState and useReducer

React provides two primary hooks for managing state: useState and useReducer. While both can manage local component state, they serve different use cases and offer different trade-offs in terms of complexity and maintainability.

useState is the simpler option, ideal for state that doesn't involve complex logic or multiple related values. When you call useState, you get a state value and a setter function. You pass the new value directly to the setter to update state, and React handles the rest.

useReducer is designed for complex state logic where the next state depends on the previous state or when multiple state values are closely related. With useReducer, you provide a reducer function and an initial state. React gives you the current state and a dispatch function--you dispatch actions rather than setting state directly. As explained in the React useReducer documentation, this consolidation makes your code more organized and easier to maintain.

ScenarioRecommendationReason
Simple independent state valuesuseStateLess boilerplate, direct updates
State updates depend on previous stateuseReducerBuilt-in access to previous state
Multiple related state valuesuseReducerConsolidates logic in one place
Complex validation logicuseReducerKeeps components clean
Many related updates in one handleruseReducerCentralizes update logic
Global app stateRedux/Redux ToolkitShared state across components

Here are signs that useReducer might be better than useState for your situation: when state updates depend on the previous state in complex ways, when you have multiple related state values that should be updated together, when updating one piece of state requires complex calculations or validation, or when your component's event handlers are becoming cluttered with state update logic. For teams building complex React applications, our React development services can help you choose the right state management strategy for your specific use case.

Writing Your First Reducer

Let's walk through creating a reducer for a simple todo application. This practical example will demonstrate the key concepts and patterns you'll use in real projects.

First, you need to define your initial state and action types. For a todo app, your initial state might be an array of todo objects, and you might support actions like adding todos, toggling completion status, and deleting todos.

The reducer function uses a switch statement to handle different action types. Each case returns a new state based on the action, while the default case returns the existing state unchanged:

const initialState = { todos: [], filter: 'all' };

function todoReducer(state = initialState, action) {
 switch (action.type) {
 case 'ADD_TODO':
 return {
 ...state,
 todos: [...state.todos, action.payload]
 };

 case 'TOGGLE_TODO':
 return {
 ...state,
 todos: state.todos.map(todo =>
 todo.id === action.payload
 ? { ...todo, completed: !todo.completed}
 : todo
 )
 };

 case 'DELETE_TODO':
 return {
 ...state,
 todos: state.todos.filter(todo => todo.id !== action.payload)
 };

 case 'SET_FILTER':
 return {
 ...state,
 filter: action.payload
 };

 default:
 return state;
 }
}

Notice how every case returns a new object using spread syntax and array methods that create new arrays. The spread operator copies the existing state properties, and the updated property (todos) gets a new array with the changes applied. This pattern ensures that Redux can detect state changes and trigger re-renders appropriately.

Redux Toolkit and Modern Reducer Patterns

While traditional Redux requires writing reducers manually, Redux Toolkit simplifies this significantly through the createSlice function. createSlice generates action creators and action types automatically, and it uses the Immer library internally to allow you to write "mutating" syntax that gets converted to immutable updates under the hood.

With Redux Toolkit, the same todo reducer can be written more concisely:

import { createSlice } from '@reduxjs/toolkit';

const todoSlice = createSlice({
 name: 'todos',
 initialState: { items: [], filter: 'all' },
 reducers: {
 addTodo: (state, action) => {
 state.items.push(action.payload);
 },
 toggleTodo: (state, action) => {
 const todo = state.items.find(t => t.id === action.payload);
 if (todo) todo.completed = !todo.completed;
 },
 deleteTodo: (state, action) => {
 state.items = state.items.filter(t => t.id !== action.payload);
 },
 setFilter: (state, action) => {
 state.filter = action.payload;
 }
 }
});

export const { addTodo, toggleTodo, deleteTodo, setFilter } = todoSlice.actions;
export default todoSlice.reducer;

The "mutations" inside the reducer functions are actually safe because Immer intercepts them and produces immutable updates. This makes reducer code significantly more readable while maintaining all the benefits of immutable state management. As noted in the LogRocket Redux tutorial, this approach has become the recommended standard for Redux development.

Common Patterns and Best Practices

Organizing State Shape

How you structure your Redux state has a significant impact on code maintainability. Several patterns have emerged from the community that help keep reducers focused and manageable.

The first pattern is normalizing state shape--storing entities by ID rather than in arrays. If your application manages many items of the same type (like todos, users, or products), storing them in an object keyed by ID makes lookups and updates more efficient and avoids duplicate items.

// Normalized state shape
{
 todos: {
 byId: {
 'todo-1': { id: 'todo-1', text: 'Learn Redux', completed: true },
 'todo-2': { id: 'todo-2', text: 'Build app', completed: false }
 },
 allIds: ['todo-1', 'todo-2']
 }
}

The second pattern is splitting state by domain. Rather than having one giant state object, divide it into slices that each handle a specific domain of your application. Redux's combineReducers function allows you to split your root reducer into separate functions that each manage their own slice of state. Our web development services team specializes in architecting scalable state management solutions for complex applications.

The third pattern is keeping state minimal. Only store in Redux what needs to be accessed by multiple components or needs to persist across sessions. Local UI state (like whether a modal is open) can often stay in component state using useState or useReducer.

Handling Complex Updates

When state updates become complex, several techniques can help. For deeply nested state, consider using helper functions that handle specific update patterns. Libraries like Immer (built into Redux Toolkit) can simplify nested updates significantly.

For updates that depend on previous state or involve complex calculations, ensure your reducer logic is clear and well-documented. Consider extracting complex update logic into named helper functions that can be tested independently.

When handling arrays of objects, common operations include adding new items (spread operator), updating items (map with copying), removing items (filter), and sorting (spread with sort). Each of these operations requires creating a new array, and any items being modified need to be copied as well:

// Add item
return { ...state, todos: [...state.todos, newTodo] };

// Update item
return {
 ...state,
 todos: state.todos.map(todo =>
 todo.id === action.payload.id ? { ...todo, ...action.payload } : todo
 )
};

// Remove item
return {
 ...state,
 todos: state.todos.filter(todo => todo.id !== action.payload)
};

// Update nested object
return {
 ...state,
 user: {
 ...state.user,
 profile: {
 ...state.user.profile,
 settings: {
 ...state.user.profile.settings,
 theme: action.payload
 }
 }
 }
};

When to Use Reducers

Reducers excel in several scenarios across modern web development. Understanding when to apply this pattern helps you make better architectural decisions.

In large React applications with complex state requirements, Redux (and its reducer pattern) helps prevent the prop drilling problem and provides a single source of truth for application state. Components can access and update state without passing props through intermediate layers.

When debugging is critical, Redux DevTools provide powerful capabilities including action logging, state inspection, and time-travel debugging. These tools can save hours when tracking down state-related bugs, as noted by the Redux Fundamentals documentation.

For applications with predictable state updates, where the same action always produces the same result, reducers provide excellent structure. This predictability also makes testing straightforward--you can verify reducer behavior by calling it with specific inputs and asserting on outputs.

However, reducers aren't always the right choice. For simple applications or local component state that doesn't need to be shared, useState or React's built-in context may be more appropriate. The Redux documentation recommends starting simple and introducing Redux only when your application's complexity justifies it.

For smaller applications or those just getting started with React, our JavaScript development services can help you build a solid foundation before introducing more complex state management patterns.

Debugging Redux Applications

One of Redux's greatest strengths is its excellent debugging capabilities. The Redux DevTools browser extension allows you to inspect every action dispatched, see the state before and after each action, and even travel back and forth through your application's history.

To enable DevTools, you'll need to install the browser extension and configure your store to use it. In development mode, the store can be enhanced with devTools compose from Redux, which provides the connection between your store and the browser extension. The Redux documentation provides detailed setup instructions for integrating DevTools into your application.

When debugging with DevTools, you can see the complete history of actions that led to any state. This is invaluable for tracking down bugs--you can pause at any point in history, inspect the state, and step forward or backward to understand exactly how the state evolved.

Time-travel debugging is particularly powerful. If you encounter a bug, you can scroll back to just before the bug appeared, inspect the state, and replay actions to understand what went wrong. You can even modify action payloads and replay to see different outcomes. This capability, unique to Redux's architecture, makes debugging complex state interactions significantly easier than with other state management approaches.

Testing Reducers

Because reducers are pure functions, they're straightforward to test. A typical reducer test calls the reducer with a specific state and action, then asserts that the returned state matches expectations.

The key testing principles are to test each action type with realistic inputs, verify that state is properly copied (not mutated), test edge cases like empty initial state and unexpected action types, and test complex updates involving nested state or arrays. As documented in the LogRocket Redux tutorial, these principles ensure comprehensive test coverage.

import todoReducer from './todoReducer';

describe('todoReducer', () => {
 test('ADD_TODO adds a new todo', () => {
 const state = { todos: [], filter: 'all' };
 const action = {
 type: 'ADD_TODO',
 payload: { id: 1, text: 'Test', completed: false }
 };
 const result = todoReducer(state, action);

 expect(result.todos).toHaveLength(1);
 expect(result.todos[0].text).toBe('Test');
 expect(state.todos).toHaveLength(0); // Original state unchanged
 });

 test('TOGGLE_TODO flips completion status', () => {
 const state = {
 todos: [{ id: 1, text: 'Test', completed: false }],
 filter: 'all'
 };
 const action = { type: 'TOGGLE_TODO', payload: 1 };
 const result = todoReducer(state, action);

 expect(result.todos[0].completed).toBe(true);
 expect(state.todos[0].completed).toBe(false); // Original unchanged
 });

 test('DELETE_TODO removes a todo', () => {
 const state = {
 todos: [
 { id: 1, text: 'Keep', completed: false },
 { id: 2, text: 'Remove', completed: false }
 ],
 filter: 'all'
 };
 const action = { type: 'DELETE_TODO', payload: 2 };
 const result = todoReducer(state, action);

 expect(result.todos).toHaveLength(1);
 expect(result.todos[0].id).toBe(1);
 });

 test('returns default state for unknown action', () => {
 const state = { todos: [], filter: 'all' };
 const action = { type: 'UNKNOWN_ACTION' };
 const result = todoReducer(state, action);

 expect(result).toEqual(state);
 });
});

The last assertion in each test is crucial--it verifies that the reducer returned a new state object rather than mutating the original. This immutability is fundamental to Redux's architecture and must be maintained even in your tests.

Conclusion

Reducers are a powerful pattern for managing application state in a predictable, testable way. By understanding the core principles--immutability, pure functions, and action-based updates--you can build applications that are easier to debug, maintain, and scale.

Whether you choose traditional Redux with manual reducers or the more modern Redux Toolkit approach, the fundamental concepts remain the same. The reducer pattern helps you organize complex state logic, provides excellent debugging capabilities, and ensures your state updates are always predictable and traceable.

Start with simple use cases, apply the three core rules consistently, and leverage tools like Redux DevTools to understand your application's behavior. As your application grows, you'll find the reducer pattern scales well and keeps your codebase organized.

If you're building complex React applications and need guidance on implementing state management patterns effectively, our team of experienced developers can help. We specialize in React application development and can help you architect scalable solutions that leverage modern patterns like Redux for predictable state management.

Sources

  1. Redux Fundamentals Tutorial - Core reducer concepts, pure functions, and state management principles
  2. React: Extracting State Logic into a Reducer - useReducer hook patterns and React state management
  3. LogRocket: Understanding Redux Tutorial - Practical examples and real-world Redux patterns

Ready to Build Better Web Applications?

Our team of experienced developers specializes in modern JavaScript frameworks and state management patterns.