How Redux Reducers Work

Master the core pattern of Redux state management with practical examples and modern best practices

What Is a Reducer?

A reducer is a function that receives the current state and an action object, decides how to update the state if necessary, and returns the new state. The signature is simple: (state, action) => newState.

Reducers get their name from the JavaScript Array.reduce() method, which takes an array of values and reduces them to a single value. Similarly, Redux reducers take a sequence of actions and reduce them to a single state update.

The Reducer Function Signature

Every reducer follows this pattern:

function myReducer(state = initialState, action) {
 // Examine the action type
 // Return new state based on action
 return newState;
}

The function takes two parameters: the current state (with a default value for initial state) and an action object. It returns the new state value. For developers working with React applications, understanding reducers is essential for building predictable state management patterns.

Reducer Fundamentals

Key concepts every developer needs to understand

Pure Functions

Reducers must be pure, meaning same inputs always produce same outputs with no side effects

Immutable Updates

Never modify state directly--always return new copies of state objects and arrays

Action Handling

Actions are plain objects with a required `type` field and optional `payload`

Default State

Use default parameters to provide initial state when Redux calls the reducer

Understanding Actions

Actions are plain JavaScript objects that describe what happened in your application. Every action must have a type field, and optionally can include a payload with additional data.

// Action with type only
const incrementAction = {
 type: 'counter/increment'
};

// Action with payload
const addTodoAction = {
 type: 'todos/todoAdded',
 payload: {
 id: 1,
 text: 'Learn Redux'
 }
};

The action type follows a naming convention of domain/eventName, making it clear where the action originates and what it does. This convention helps organize actions across larger applications. When building scalable web applications, consistent action naming becomes critical for maintainability as your codebase grows.

Writing Your First Reducer

Let's build a simple counter reducer to demonstrate the core concepts:

const initialState = { value: 0 };

function counterReducer(state = initialState, action) {
 if (action.type === 'counter/increment') {
 return { value: state.value + 1 };
 }
 return state;
}

This reducer handles the increment action by returning a new state object with the value incremented by one. For any other action, it returns the current state unchanged.

Key Points About This Reducer

  1. Default parameter - state = initialState provides the initial value when the store is first created
  2. Immutable update - We create a new object { value: state.value + 1 } rather than modifying state directly
  3. Default return - The final return state ensures the reducer always returns something

Handling Multiple Actions

As applications grow, reducers need to handle many action types. The switch statement provides a clean way to organize multiple cases:

function todoReducer(state = [], action) {
 switch (action.type) {
 case 'todos/todoAdded':
 return [
 ...state,
 {
 id: generateId(),
 text: action.payload.text,
 completed: false
 }
 ];
 case 'todos/todoToggled':
 return state.map(todo =>
 todo.id === action.payload.id
 ? { ...todo, completed: !todo.completed }
 : todo
 );
 case 'todos/todoDeleted':
 return state.filter(todo => todo.id !== action.payload.id);
 default:
 return state;
 }
}

Pattern: Spread Operator for Arrays

When adding items to an array, use the spread operator to create a new array:

return [...state, newItem]; // Correct - immutable
state.push(newItem); // Wrong - mutates state

Pattern: Map for Transformations

When updating items in an array, use map with object spread:

return state.map(item =>
 item.id === action.payload.id
 ? { ...item, completed: true }
 : item
);

Pattern: Filter for Removals

When removing items, use filter:

return state.filter(item => item.id !== action.payload.id);

The Rules of Reducers

Redux imposes specific rules on reducers to ensure predictable state updates:

Rule 1: Calculate State Based Only on Arguments

A reducer should only use state and action to calculate the new state. It cannot depend on external variables, random number generators, or make decisions based on time:

// Wrong - depends on external variable
const externalId = generateId(); // Not allowed
return { ...state, id: externalId };

// Wrong - depends on current time
const timestamp = Date.now(); // Not allowed
return { ...state, timestamp };

Rule 2: Never Mutate State

This is the most common mistake in Redux. Always create new copies of objects and arrays:

// Wrong - mutates state directly
state.value = 5;
return state;

// Correct - creates new object
return { ...state, value: 5 };

// Wrong - mutates nested object
state.user.profile.name = 'New Name';
return state;

// Correct - copies all levels that change
return {
 ...state,
 user: {
 ...state.user,
 profile: {
 ...state.user.profile,
 name: 'New Name'
 }
 }
};

Rule 3: No Side Effects

Reducers cannot:

  • Make HTTP requests
  • Call functions that return different results on successive calls
  • Modify their arguments
  • Mutate external state

Combining Multiple Reducers

Large applications have many pieces of state. Redux allows you to split reducers into smaller functions and combine them with combineReducers:

// reducers/todos.js
function todosReducer(state = [], action) {
 switch (action.type) {
 case 'todos/todoAdded':
 return [...state, action.payload];
 default:
 return state;
 }
}

// reducers/filters.js
function filtersReducer(state = 'all', action) {
 switch (action.type) {
 case 'filters/statusChanged':
 return action.payload;
 default:
 return state;
 }
}

// root reducer
const rootReducer = combineReducers({
 todos: todosReducer,
 filters: filtersReducer
});

const store = createStore(rootReducer);

The resulting state object mirrors the keys you provide to combineReducers:

console.log(store.getState());
// {
// todos: [],
// filters: 'all'
// }

This modular approach to state management makes applications easier to maintain and test. Professional web development services leverage these patterns to build scalable frontend architectures.

Modern Redux with Redux Toolkit

Redux Toolkit (RTK) is the official recommended approach for writing Redux. It simplifies reducer creation with createSlice:

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

const counterSlice = createSlice({
 name: 'counter',
 initialState: { value: 0 },
 reducers: {
 increment(state) {
 // RTK allows direct mutation!
 state.value += 1;
 },
 decrement(state) {
 state.value -= 1;
 },
 incrementByAmount(state, action) {
 state.value += action.payload;
 }
 }
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;

Why Redux Toolkit Changes the Rules

Redux Toolkit uses a library called Immer under the hood. When you "mutate" state in a createSlice reducer, Immer secretly creates immutable updates. This makes code more readable while maintaining immutability:

// This looks like mutation...
state.value += 1;

// ...but Immer converts it to this internally
return { ...state, value: state.value + 1 };

By reducing boilerplate and simplifying immutable updates, Redux Toolkit enables developers to build modern web applications more efficiently while following established best practices.

Common Reducer Mistakes

Mistake 1: Forgetting the Default Case

Always return state for unhandled actions:

// Wrong - returns undefined for unknown actions
function reducer(state, action) {
 switch (action.type) {
 case 'known':
 return newState;
 }
}

// Correct - returns state for unknown actions
function reducer(state, action) {
 switch (action.type) {
 case 'known':
 return newState;
 default:
 return state;
 }
}

Mistake 2: Mutating State

Direct mutations bypass Redux's change detection:

// Wrong
state.items.push(newItem);
return state;

// Correct
return { ...state, items: [...state.items, newItem] };

Mistake 3: Returning undefined for Initial Call

The default parameter pattern prevents this issue:

// Wrong - initial state is undefined
function reducer(state, action) {
 return state.value; // Crashes on first call!
}

// Correct - default parameter provides initial state
function reducer(state = 0, action) {
 return state;
}

Best Practices for Reducers

Keep Reducers Small and Focused

Each reducer should handle one slice of state:

// Split this...
function largeReducer(state = {}, action) {
 switch (action.type) {
 case 'user/update':
 return { ...state, user: action.payload };
 case 'products/add':
 return { ...state, products: [...state.products, action.payload] };
 }
}

// Into this...
const userReducer = (state = {}, action) => {
 switch (action.type) {
 case 'user/update':
 return { ...state, ...action.payload };
 default:
 return state;
 }
};

const productsReducer = (state = [], action) => {
 switch (action.type) {
 case 'products/add':
 return [...state, action.payload];
 default:
 return state;
 }
};

Use Action Type Constants

Define action types as constants to prevent typos:

// actionTypes.js
export const INCREMENT = 'counter/increment';
export const ADD_TODO = 'todos/todoAdded';

// reducer.js
import { INCREMENT, ADD_TODO } from './actionTypes';

function reducer(state, action) {
 switch (action.type) {
 case INCREMENT:
 return state + 1;
 case ADD_TODO:
 return [...state, action.payload];
 default:
 return state;
 }
}

Normalize Complex State

For arrays of objects, consider normalization to make updates more efficient:

// Use normalized shape instead of nested arrays
const postsState = {
 ids: [1, 2],
 byId: {
 1: { id: 1, commentIds: [101, 102] },
 2: { id: 2, commentIds: [103] }
 },
 comments: {
 101: { id: 101, text: 'Great post!' },
 102: { id: 102, text: 'Thanks for sharing' },
 103: { id: 103, text: 'Interesting perspective' }
 }
};

Testing Reducers

Because reducers are pure functions, they're straightforward to test:

import counterReducer from './counterReducer';

test('returns initial state', () => {
 expect(counterReducer(undefined, { type: '@@INIT' }))
 .toEqual({ value: 0 });
});

test('handles increment', () => {
 const initialState = { value: 0 };
 const action = { type: 'counter/increment' };
 expect(counterReducer(initialState, action))
 .toEqual({ value: 1 });
});

test('handles decrement', () => {
 const initialState = { value: 5 };
 const action = { type: 'counter/decrement' };
 expect(counterReducer(initialState, action))
 .toEqual({ value: 4 });
});

test('returns state for unknown actions', () => {
 const initialState = { value: 10 };
 const action = { type: 'unknown' };
 expect(counterReducer(initialState, action))
 .toBe(initialState);
});

The predictability of reducers makes them excellent candidates for comprehensive test coverage. This is one reason why Redux remains a preferred choice for enterprise web development projects where reliability and maintainability are critical requirements.

Conclusion

Redux reducers are pure functions at the core of Redux state management. They take the current state and an action, then return a new state. Understanding the rules--purity, immutability, and no side effects--is essential for building predictable applications.

For modern Redux development, Redux Toolkit's createSlice provides a simpler API while maintaining immutability through Immer. Whether you use vanilla Redux or Redux Toolkit, the fundamental concepts remain the same: actions describe what happened, and reducers determine how state changes in response.

Proper state management is a critical component of professional web development services, ensuring applications remain maintainable and scalable as they grow. When building complex React applications, understanding Redux patterns helps developers create clean, predictable codebases that are easier to debug and test.


Sources

  1. Redux.js.org - Redux Fundamentals Part 3
  2. Redux.js.org - Redux Essentials Part 1
  3. DEV Community - Just Redux: The Complete Guide

Frequently Asked Questions

Ready to Master Modern Web Development?

Our team of expert developers can help you build scalable applications with modern state management patterns.