Redux - Pure Functions
Redux - Pure Functions
Section titled “Redux - Pure Functions”A core principle of Redux is that state transformations are handled by pure functions, specifically within reducers. Understanding what makes a function ‘pure’ is crucial for writing correct Redux code.
What is a Pure Function?
Section titled “What is a Pure Function?”A function is considered pure if it satisfies two main conditions:
- Same Input, Same Output: It always returns the same result if called with the same arguments. Its output depends only on its input arguments.
- No Side Effects: Its execution does not cause any observable effects outside the function’s scope. This means it doesn’t:
-
- Modify its input arguments (no mutation).
-
- Mutate global variables or application state directly.
-
- Perform I/O operations (like API calls, console logging*, or writing to disk).
-
- Call other functions that have side effects.
*Note: While console.log technically is a side effect, it’s often tolerated during debugging but should ideally be removed from final reducer code.
Example of a pure function:
// Pure function: Always returns the same output for the same input, no side effects.const multiply = (a, b) => a * b;
console.log(multiply(2, 3)); // Always outputs 6console.log(multiply(2, 3)); // Still outputs 6Example of an impure function (due to side effect - modifying external state):
let counter = 0;
// Impure function: Modifies a variable outside its scope.const incrementCounter = () => { counter += 1; return counter;};
console.log(incrementCounter()); // Output: 1 (and counter is now 1)console.log(incrementCounter()); // Output: 2 (and counter is now 2)Example of an impure function (due to modifying input - mutation):
// Impure function: Modifies (mutates) its input argument.const addItemToArrayImmutably = (arr, item) => { // Correct: Create a *new* array with the item added return [...arr, item];}
// Impure function: Modifies (mutates) its input argument.const addItemToArrayMutably = (arr, item) => { arr.push(item); // Incorrect: Modifies the original array! return arr;}
const originalArray = [1, 2];const newArray = addItemToArrayMutably(originalArray, 3);
console.log(originalArray); // Output: [1, 2, 3] - The original was changed!console.log(newArray); // Output: [1, 2, 3]console.log(originalArray === newArray); // Output: true - It's the same mutated array objectWhy Must Reducers Be Pure?
Section titled “Why Must Reducers Be Pure?”Redux relies heavily on the purity of reducers for several reasons:
- Predictability: Pure functions guarantee that the next state depends only on the current state and the action. This makes the state flow predictable and easier to debug.
- Performance Optimization: UI libraries like React Redux perform shallow equality checks on state objects to determine if components need to re-render. If you mutate the state object instead of returning a new one, these checks fail (because the object reference hasn’t changed), and the UI might not update correctly.
- Time Travel Debugging: Tools like Redux DevTools rely on reducers being pure to accurately calculate past states and enable features like action skipping and replaying.
- Testability: Pure functions are easy to test. You just provide input state and an action, and assert the output state, without needing complex mocks or setups.
Writing Pure Reducers
Section titled “Writing Pure Reducers”When writing reducers, always ensure you return a new state object or array if changes are needed. Never modify the existing state argument directly.
Common techniques for immutable updates include:
- Spread syntax (
...) for objects and arrays. - Array methods that return new arrays (
.map(),.filter(),.concat(),.slice()). Avoid methods that mutate (.push(),.splice()). - Object.assign({}, …)` (less common now than spread syntax).
const initialState = { count: 0, user: null};
function counterReducer(state = initialState, action) { switch (action.type) { case 'counter/increment': // Correct: Return a new object with the updated count return { ...state, // Copy existing state properties count: state.count + 1 // Update the count }; case 'user/set': // Correct: Return a new object with the updated user return { ...state, user: action.payload }; // Incorrect: Mutating the state argument! /* case 'counter/increment': state.count += 1; // MUTATION! return state; // Returning the mutated original state */ default: // If no change, return the existing state object return state; }}Note on Redux Toolkit and Immer
Section titled “Note on Redux Toolkit and Immer”Redux Toolkit’s createSlice uses a library called Immer internally. Immer allows you to write code that looks like direct mutation within your reducer functions, but it safely tracks your changes and produces a correct, immutably updated state behind the scenes. This significantly simplifies writing immutable update logic.
// Inside createSlice (uses Immer)reducers: { increment(state) { // This LOOKS like mutation, but Immer handles it immutably state.count += 1; }, setUser(state, action) { state.user = action.payload; }}Even when using Immer via Redux Toolkit, the underlying principle remains: the actual state update performed by Redux is immutable, thanks to Immer’s abstraction.