Why Redux Needs Reducers to Be "Pure Functions"
Redux has taken the frontend world by storm since its release in 2015. Created by Dan Abramov and Andrew Clark, Redux has become the go-to state management library for complex JavaScript apps. Over 60% of React apps use Redux, and it‘s been downloaded over 20 million times per month on NPM.
At the core of Redux‘s philosophy are functional programming principles, and perhaps no principle is more important than the idea that reducer functions must be "pure". But what exactly does "pure" mean, and why is this principle so crucial to Redux‘s success? Let‘s take a deep dive into the world of pure functions and how they power Redux‘s elegant and robust state management model.
Characteristics of Pure Functions
Before we explore why Redux requires pure functions, let‘s clarify what we mean by "pure". A pure function has the following properties:
- Its output depends solely on its input parameters
- It does not modify its input parameters
- It does not depend on or modify any external state
- It does not produce any side effects
In essence, pure functions are completely predictable and self-contained. Given the same inputs, they always produce the same output, and they don‘t modify or depend on any state outside of their own scope.
Here‘s a classic example of a pure function:
function add(x, y) {
return x + y;
}
No matter how many times you call add(2, 3)
, it will always return 5
. The function doesn‘t modify x
or y
, it doesn‘t depend on any external state, and it doesn‘t produce any side effects. Its output is purely a function of its inputs.
In contrast, here‘s an example of an impure function:
let z = 5;
function impureAdd(x, y) {
z++;
return x + y + z;
}
This function violates the principles of purity. It depends on and modifies the external variable z
, and it produces the side effect of incrementing z
. If you call impureAdd(2, 3)
multiple times, you‘ll get different results each time as z
keeps incrementing.
Why Purity Matters for Redux
Now that we understand what pure functions are, let‘s explore why they‘re so critical to Redux‘s design. There are several key reasons why Redux requires reducers to be pure:
1. Predictable State Updates
The primary reason Redux requires pure reducers is to enable predictable state updates. In Redux, the entire app‘s state is represented by a single plain JavaScript object, and the only way to update the state is by dispatching actions that describe what happened. Reducers then take the current state and the dispatched action and return the next state.
If reducers were impure, the state updates would become unpredictable. Multiple dispatches of the same action could produce different results if reducers were modifying external state or producing side effects. With pure reducers, Redux can predictably determine the next state based solely on the current state and action, without any external factors coming into play.
This predictability is essential for maintaining the integrity of the state over time. Here‘s a telling quote from Redux co-creator Andrew Clark on why purity matters:
"In order to achieve predictable state updates, Redux requires reducers to be pure functions. If reducers were allowed to modify state directly or depend on external state, the state updates would become unpredictable and impossible to track or reproduce."
2. Testability and Debugging
Pure reducers also make testing and debugging Redux apps much easier. Because pure functions always produce the same output for the same input, unit tests for reducers can be very simple and straightforward. You simply call the reducer with some state and action inputs and assert that the expected state is returned.
Here‘s an example test for a simple todos reducer:
it(‘should add a new todo‘, () => {
const state = [{ id: 1, text: ‘Learn Redux‘ }];
const action = { type: ‘ADD_TODO‘, text: ‘Build app‘ };
const newState = todosReducer(state, action);
expect(newState).toEqual([
{ id: 1, text: ‘Learn Redux‘ },
{ id: 2, text: ‘Build app‘ }
]);
});
Note how the test doesn‘t need any complex setup or mocking – it just calls the reducer with plain JS objects and makes an assertion on the result. The purity of the reducer makes it extremely easy to test in isolation.
This testability extends to debugging as well. With pure reducers, you can log every state change and reliably trace how the state evolved over time without any hidden dependencies or side effects. The Redux DevTools also allow you to time travel through the state history, which would be impossible without the predictability of pure functions.
3. Performance Optimizations
Pure reducers also enable significant performance optimizations in Redux. One of Redux‘s core concepts is that whenever the state changes, the root component of the app will re-render itself and all of its children. However, re-rendering the entire component tree on every state change would be prohibitively expensive for any non-trivial app.
Redux uses pure reducers to implement a crucial optimization. If a reducer returns the exact same state object as the previous state, Redux knows that the state hasn‘t actually changed. It performs a reference equality check (e.g. prevState === nextState
) and if the check passes, Redux skips the re-render entirely.
This optimization is only possible because of the purity of reducer functions. If reducers were mutating state directly, Redux would have no way to efficiently determine if the state had changed. By requiring reducers to return new state objects, Redux can quickly check for reference equality and avoid unnecessary re-renders.
To put this in perspective, the Redux docs state that:
"Memoizing components using
React.memo()
or similar can minimize unnecessary re-renders when the reducer returns the same state object."
This kind of memoization would be impossible without the guarantee of pure reducers.
4. Time Travel Debugging and Undo/Redo
Another powerful feature enabled by pure reducers is time travel debugging. Redux DevTools allow you to see a list of all the dispatched actions and resulting state changes over time. You can "go back in time" by selecting a previous state and seeing exactly how the app looked at that point.
This time travel functionality relies on the fact that reducers are pure functions. At any point, Redux can reconstruct the previous states by simply re-running the reducers with the previous actions – there‘s no hidden state or side effects that could cause the previous states to change.
In addition to time travel debugging, pure reducers also enable simple undo/redo functionality. By keeping a stack of previous states and actions, you can easily implement undo/redo behavior by simply popping states off the stack and dispatching the inverse actions.
Here‘s a snippet of how this might look:
function undoable(reducer) {
const initialState = {
past: [],
present: reducer(undefined, {}),
future: []
};
return function(state = initialState, action) {
const { past, present, future } = state;
switch (action.type) {
case ‘UNDO‘:
const previous = past[past.length - 1];
const newPast = past.slice(0, past.length - 1);
return {
past: newPast,
present: previous,
future: [present, ...future]
};
case ‘REDO‘:
const next = future[0];
const newFuture = future.slice(1);
return {
past: [...past, present],
present: next,
future: newFuture
};
default:
const newPresent = reducer(present, action);
if (present === newPresent) {
return state;
}
return {
past: [...past, present],
present: newPresent,
future: []
};
}
};
}
Note how this undoable
reducer enhancer relies on the fact that the wrapped reducer
is pure. It can safely keep a stack of previous states and re-apply them without any unexpected side effects.
5. Avoiding Race Conditions and Bugs
Finally, pure reducers help avoid a whole class of bugs and race conditions associated with shared mutable state. If reducers were allowed to directly mutate state, you could end up with subtle bugs where different parts of the app are unintentionally modifying the same state in unpredictable ways.
By enforcing immutability and requiring reducers to return new state objects, Redux sidesteps these issues entirely. The state can only be updated in one place (the reducer) and only in response to intentionally dispatched actions. This greatly simplifies the mental model and eliminates many common sources of bugs.
To quote Dan Abramov, the creator of Redux:
"I find many people instinctively want to put side effects and mutation into their reducers. They‘re ‘easier‘ that way. But I urge them not to. Keeping reducers pure doesn‘t just make the state updates more predictable—it also makes your own thought process as you write them more predictable."
Best Practices for Writing Pure Reducers
We‘ve seen how pure reducers are essential to Redux‘s core guarantees and functionality. So as you‘re writing your own reducers, keep these best practices in mind:
- Never mutate the
state
argument – always return new objects - Use spread syntax or
Object.assign()
to create new objects with updated properties - Keep reducers small, focused, and composable
- Don‘t perform side effects like API calls or logging
- Don‘t call impure functions like
Date.now()
orMath.random()
- Use pure utility functions for common immutable update logic
Here‘s an example of a well-structured, pure reducer:
const initialState = {
todos: [],
visibilityFilter: ‘SHOW_ALL‘
};
function updateObject(oldObject, newValues) {
return Object.assign({}, oldObject, newValues);
}
function updateItemInArray(array, itemId, updateItemCallback) {
const updatedItems = array.map(item => {
if(item.id !== itemId) {
return item;
}
const updatedItem = updateItemCallback(item);
return updatedItem;
});
return updatedItems;
}
function appReducer(state = initialState, action) {
switch(action.type) {
case ‘ADD_TODO‘:
return updateObject(state, {
todos: [
...state.todos,
{
id: action.id,
text: action.text,
completed: false
}
]
});
case ‘TOGGLE_TODO‘:
return updateObject(state, {
todos: updateItemInArray(state.todos, action.id, todo => updateObject(todo, {completed: !todo.completed}))
});
case ‘SET_VISIBILITY_FILTER‘:
return updateObject(state, {visibilityFilter: action.filter});
default:
return state;
}
}
Note how this reducer uses pure utility functions like updateObject
and updateItemInArray
to avoid direct mutation and keep the update logic concise and readable.
Fixing Impure Reducers
If you encounter bugs or unexpected behavior in your Redux app, it‘s worth double-checking that all your reducers are truly pure. Here are a few common mistakes that can introduce impurity:
1. Mutating the state directly
function impureReducer(state = initialState, action) {
switch(action.type) {
case ‘TOGGLE_TODO‘:
let todo = state.todos.find(t => t.id === action.id);
todo.completed = !todo.completed; // Mutating state directly!
return state;
// ...
}
}
In this example, the reducer is directly mutating the todo
object within the state. This mutates the previous state and breaks Redux‘s expectation of immutability. The correct approach is to create a new todos
array with an updated object:
case ‘TOGGLE_TODO‘:
return updateObject(state, {
todos: updateItemInArray(state.todos, action.id, todo => updateObject(todo, {completed: !todo.completed}))
});
2. Depending on external state
let apiData = {};
function impureReducer(state = initialState, action) {
switch(action.type) {
case ‘UPDATE_DATA‘:
apiData = action.payload; // Mutating external state!
return state;
case ‘FETCH_DATA‘:
return updateObject(state, {data: apiData}); // Depending on external state!
// ...
}
}
This reducer is depending on and mutating the apiData
variable outside its scope. This external dependency makes the reducer impure and unpredictable. Instead, the apiData
should be managed within the Redux store itself to keep everything pure and self-contained.
3. Performing side effects
function impureReducer(state = initialState, action) {
switch(action.type) {
case ‘FETCH_DATA‘:
fetch(‘https://example.com/data‘) // Performing side effect!
.then(response => response.json())
.then(data => {
return updateObject(state, {data});
});
// ...
}
}
This reducer is performing an asynchronous side effect by making an API call. Side effects like this should be handled in middleware like Redux Thunk or Redux Saga, not in the reducer itself. The reducer should stay pure and only synchronously return the next state.
Conclusion
Redux‘s insistence on pure reducers may seem strict at first, but it‘s absolutely essential to maintaining a predictable and reliable state container. By keeping your reducers pure, you reap the benefits of easy testing, debugging, optimized performance, time travel, and freedom from subtle bugs and race conditions.
Here‘s a final quote from Redux maintainer Mark Erikson on the importance of pure reducers:
"Redux‘s core idea is that all your state is kept in a single store, and the only way to change state is to dispatch actions that describe what happened. Reducers are functions that calculate the new state based on the current state and the action. They must be pure functions that return new state objects instead of mutating the existing state. This ensures that Redux state management stays predictable."
So as you build your Redux apps, resist the temptation to mutate state or introduce dependencies and side effects in your reducers. Embrace the constraints of pure functions and let Redux do the heavy lifting of predictable state management. Your future self will thank you for keeping your state pristine!