~/.claude/skills/ or any Agent-Skills-compatible runtime for AI-assisted Reducers work.
Reducer
- Pure function mapping (state, action) → newState
- Foundation of predictable state management
- Enables time-travel debugging and state reproducibility
Detailed Description
A reducer in state management is a pure function that takes the current state and an action as arguments, and returns a new state. Reducers are fundamental to managing state in libraries like Redux, but the concept is applicable to other state management solutions as well. It’s particularly useful for handling CRUD (Create, Read, Update, Delete) operations in a predictable manner.
The term “reducer” comes from the JavaScript Array.reduce() method—just as reduce transforms an array into a single value by applying a function to each element, Redux reducers transform a sequence of actions into the current state. Each action is like an event in time, and the reducer determines how that event changes the state.
Key characteristics of reducers:
- Pure functions - No side effects, same inputs always produce same outputs
- Immutable updates - Always return new state objects, never mutate existing state
- Predictable transformations - Determine how state changes in response to actions
- Composable - Complex reducers built from simpler reducer functions
- Testable - Pure functions are trivial to unit test
Key Insight
Reducers transform Redux from “just another state container” into a predictable state machine—every state transition is a pure function, meaning given the same state and action, you’ll always get the exact same result. This purity unlocks time-travel debugging, hot reloading, and deterministic testing that’s impossible with mutable state. Think of reducers as the “laws of physics” for your application state: they define how your state universe evolves, and unlike messy imperative code, these laws can be tested, replayed, and proven correct.
Code Examples
Basic Example: Todo reducer across state libraries
The same “todo slice” implemented four different ways, pulled from the actual templates. Notice that Classic Redux and Redux Saga use the same switch/case reducer (Saga adds request/success/error branches); RTK compresses the same effect into createSlice with Immer-backed mutating-looking code; NgRx uses createReducer(on(...)). Pinia doesn’t have a reducer at all — its “actions” mutate the store state directly — so it isn’t listed here.
Classic Redux
// templates/chota-react-redux/src/state/todo/todo.reducer.js
import intialTodoState from "./todo.initial";
import { CREATE_TODO, DELETE_TODO, EDIT_TODO, TOGGLE_TODO, UPDATE_TODO } from "./todo.type";
const todo = (state = intialTodoState, action) => {
let todoItems = [];
switch (action.type) {
case CREATE_TODO:
return {
...state,
todoItems: [
...state.todoItems,
{ id: action.payload.id, text: action.payload.text, completed: false },
],
};
case EDIT_TODO:
return { ...state, currentTodoItem: action.payload };
case UPDATE_TODO:
todoItems = state.todoItems.map((t) =>
t.id === action.payload.id ? { ...t, text: action.payload.text } : t);
return { ...state, todoItems, currentTodoItem: intialTodoState.currentTodoItem };
case TOGGLE_TODO:
todoItems = state.todoItems.map((t) =>
t.id === action.payload.id ? { ...t, completed: !t.completed } : t);
return { ...state, todoItems };
case DELETE_TODO:
todoItems = state.todoItems.filter((t) => t.id !== action.payload.id);
return { ...state, todoItems };
default:
return state;
}
};
export default todo;
Redux Toolkit
// templates/chota-react-rtk/src/state/todo/todo.reducer.js
// createSlice + Immer lets you write mutating-looking code that still
// produces an immutable update under the hood. The slice also auto-emits
// action creators and types.
import { createSlice } from "@reduxjs/toolkit";
import intialTodoState from "./todo.initial";
let nextTodoId = 0;
export const todoSlice = createSlice({
name: "todo",
initialState: intialTodoState,
reducers: {
createTodo: (state, action) => {
state.todoItems.push({ text: action.payload, completed: false, id: nextTodoId++ });
},
editTodo: (state, action) => { state.currentTodoItem = action.payload; },
updateTodo: (state, action) => {
state.todoItems = state.todoItems.map((t) =>
t.id === action.payload.id ? { ...t, text: action.payload.text } : t);
state.currentTodoItem = intialTodoState.currentTodoItem;
},
toggleTodo: (state, action) => {
state.todoItems = state.todoItems.map((t) =>
t.id === action.payload.id ? { ...t, completed: !t.completed } : t);
},
},
});
export const { createTodo, editTodo, updateTodo, toggleTodo } = todoSlice.actions;
export default todoSlice.reducer;
Redux Saga
// templates/chota-react-saga/src/state/todo/todo.reducer.js
// Same switch/case shape as Classic Redux, plus *_SUCCESS / *_ERROR branches
// because sagas dispatch those after completing async work. The React-Saga
// and WC-Saga templates share this exact reducer — the saga pattern is
// framework-agnostic.
import intialTodoState from "./todo.initial";
import {
CREATE_TODO, CREATE_TODO_SUCCESS, CREATE_TODO_ERROR,
READ_TODO, READ_TODO_SUCCESS, READ_TODO_ERROR,
TOGGLE_TODO, TOGGLE_TODO_SUCCESS, TOGGLE_TODO_ERROR,
/* ...DELETE_*, UPDATE_*, EDIT_TODO... */
} from "./todo.type";
const todo = (state = intialTodoState, action) => {
switch (action.type) {
case READ_TODO:
return { ...state, isLoading: true, isContentLoading: true };
case READ_TODO_SUCCESS:
return { ...state, isLoading: false, isContentLoading: false, todoItems: action.payload };
case READ_TODO_ERROR:
return { ...state, isLoading: false, isContentLoading: false, error: action.error };
case CREATE_TODO:
return { ...state, isLoading: true, isActionLoading: true };
case CREATE_TODO_SUCCESS:
return {
...state, isLoading: false, isActionLoading: false,
todoItems: [...state.todoItems, action.payload],
};
// ...same pattern for TOGGLE_/UPDATE_/DELETE_ triples
default:
return state;
}
};
export default todo;
NgRx
// templates/chota-angular-ngrx/src/app/state/todo/todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import initialTodoState from './todo.initial';
import {
createTodo, editTodo, updateTodo, toggleTodo, deleteTodo, loadTodos,
loadTodosRequest, loadTodosSuccess, loadTodosFail,
addTodoRequest, addTodoSuccess, addTodoFail,
} from './todo.actions';
export const todoReducer = createReducer(
initialTodoState,
on(loadTodosRequest, (state) => ({ ...state, isContentLoading: true, error: '' })),
on(loadTodosSuccess, (state) => ({ ...state, isContentLoading: false })),
on(loadTodosFail, (state, { error }) => ({ ...state, isContentLoading: false, error })),
on(loadTodos, (state, { todos }) => ({ ...state, todoItems: todos })),
on(addTodoRequest, (state) => ({ ...state, isActionLoading: true, error: '' })),
on(addTodoSuccess, (state) => ({ ...state, isActionLoading: false })),
on(addTodoFail, (state, { error }) => ({ ...state, isActionLoading: false, error })),
on(createTodo, (state, { id, text }) => ({
...state,
todoItems: [...state.todoItems, { id, text, completed: false }],
})),
on(editTodo, (state, payload) => ({ ...state, currentTodoItem: payload })),
on(toggleTodo, (state, { id }) => ({
...state,
todoItems: state.todoItems.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t),
})),
);
Across all four tabs the contract is the same: given the previous state and an action, return a new state. The surface differs in whether you write the switch yourself (Classic Redux / Saga), let a library auto-generate the switch from handlers (NgRx, RTK), or dispense with the whole pattern in favour of store-method mutations (Pinia — see the Store docs).
Practical Example: CRUD Operations Reducer
// todosReducer.js - Real-world CRUD patterns
const initialState = {
items: [],
loading: false,
error: null,
filter: 'all' // 'all' | 'active' | 'completed'
};
function todosReducer(state = initialState, action) {
switch (action.type) {
// CREATE
case 'todos/add':
return {
...state,
items: [
...state.items,
{
id: action.payload.id,
text: action.payload.text,
completed: false,
createdAt: Date.now()
}
]
};
// READ (set from API)
case 'todos/fetchStart':
return { ...state, loading: true, error: null };
case 'todos/fetchSuccess':
return {
...state,
items: action.payload,
loading: false
};
case 'todos/fetchFailure':
return {
...state,
loading: false,
error: action.payload
};
// UPDATE
case 'todos/toggle':
return {
...state,
items: state.items.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'todos/update':
return {
...state,
items: state.items.map(todo =>
todo.id === action.payload.id
? { ...todo, ...action.payload.updates }
: todo
)
};
// DELETE
case 'todos/delete':
return {
...state,
items: state.items.filter(todo => todo.id !== action.payload)
};
case 'todos/clearCompleted':
return {
...state,
items: state.items.filter(todo => !todo.completed)
};
// FILTER (UI state)
case 'todos/setFilter':
return {
...state,
filter: action.payload
};
default:
return state;
}
}
// Action creators
const addTodo = (id, text) => ({ type: 'todos/add', payload: { id, text } });
const toggleTodo = (id) => ({ type: 'todos/toggle', payload: id });
const deleteTodo = (id) => ({ type: 'todos/delete', payload: id });
Advanced Example: Reducer Composition
// Composing reducers for complex state
import { combineReducers } from 'redux';
// Individual slice reducers
function authReducer(state = { user: null, token: null }, action) {
switch (action.type) {
case 'auth/login':
return {
user: action.payload.user,
token: action.payload.token
};
case 'auth/logout':
return { user: null, token: null };
default:
return state;
}
}
function productsReducer(state = { items: [], selectedId: null }, action) {
switch (action.type) {
case 'products/load':
return { ...state, items: action.payload };
case 'products/select':
return { ...state, selectedId: action.payload };
default:
return state;
}
}
function cartReducer(state = { items: [] }, action) {
switch (action.type) {
case 'cart/add':
const existing = state.items.find(i => i.productId === action.payload.productId);
if (existing) {
// Update quantity
return {
items: state.items.map(i =>
i.productId === action.payload.productId
? { ...i, quantity: i.quantity + action.payload.quantity }
: i
)
};
} else {
// Add new item
return {
items: [...state.items, action.payload]
};
}
case 'cart/remove':
return {
items: state.items.filter(i => i.productId !== action.payload)
};
case 'cart/clear':
return { items: [] };
default:
return state;
}
}
// Combine into root reducer
const rootReducer = combineReducers({
auth: authReducer,
products: productsReducer,
cart: cartReducer
});
// State shape:
// {
// auth: { user, token },
// products: { items, selectedId },
// cart: { items }
// }
// Advanced pattern: Reducer enhancers
function withLogging(reducer) {
return (state, action) => {
console.log('Before:', state);
console.log('Action:', action);
const newState = reducer(state, action);
console.log('After:', newState);
return newState;
};
}
function withUndo(reducer) {
const initialState = {
past: [],
present: reducer(undefined, {}),
future: []
};
return (state = initialState, action) => {
const { past, present, future } = state;
switch (action.type) {
case 'UNDO':
if (past.length === 0) return state;
return {
past: past.slice(0, -1),
present: past[past.length - 1],
future: [present, ...future]
};
case 'REDO':
if (future.length === 0) return state;
return {
past: [...past, present],
present: future[0],
future: future.slice(1)
};
default:
const newPresent = reducer(present, action);
if (newPresent === present) return state;
return {
past: [...past, present],
present: newPresent,
future: []
};
}
};
}
// Usage
const enhancedReducer = withUndo(withLogging(todosReducer));
Common Mistakes
1. Mutating State Directly
Mistake: Modifying state object instead of returning new object.
// ❌ BAD: Mutating state
function badReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
state.count++; // MUTATION! Redux won't detect change
return state;
case 'ADD_ITEM':
state.items.push(action.payload); // MUTATION!
return state;
default:
return state;
}
}
// Redux uses shallow equality checks - mutations break change detection
// ✅ GOOD: Immutable updates
function goodReducer(state = { count: 0, items: [] }, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }; // New object
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload] // New array
};
default:
return state;
}
}
Why it matters: Mutations prevent Redux from detecting changes, breaking React re-renders, DevTools time-travel, and selector memoization. Always return new objects.
2. Side Effects in Reducers
Mistake: Performing async operations or side effects inside reducer.
// ❌ BAD: Side effects in reducer
function badReducer(state = {}, action) {
switch (action.type) {
case 'SAVE_DATA':
localStorage.setItem('data', action.payload); // SIDE EFFECT!
return { ...state, saved: true };
case 'FETCH_USER':
fetch('/api/user') // ASYNC SIDE EFFECT!
.then(res => res.json())
.then(user => state.user = user); // Also mutating!
return state;
case 'LOG_ACTION':
console.log('Action:', action); // SIDE EFFECT (logging)
return state;
default:
return state;
}
}
// ✅ GOOD: Pure reducer, side effects in middleware
function goodReducer(state = {}, action) {
switch (action.type) {
case 'SAVE_DATA':
return { ...state, data: action.payload, saved: true };
case 'FETCH_USER_SUCCESS':
return { ...state, user: action.payload };
case 'FETCH_USER_FAILURE':
return { ...state, error: action.payload };
default:
return state;
}
}
// Side effects handled in middleware (e.g., Redux Thunk)
const saveDataThunk = (data) => (dispatch) => {
localStorage.setItem('data', data); // Side effect here
dispatch({ type: 'SAVE_DATA', payload: data });
};
const fetchUserThunk = () => async (dispatch) => {
try {
const res = await fetch('/api/user');
const user = await res.json();
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'FETCH_USER_FAILURE', payload: error.message });
}
};
Why it matters: Reducers must be pure for time-travel debugging, testing, and predictability. Side effects belong in middleware or action creators.
3. Not Handling Default Case
Mistake: Missing default case in switch statement.
// ❌ BAD: No default case
function badReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
// Missing default case!
}
// Returns undefined for unknown actions!
}
// Later:
const state = badReducer({ count: 5 }, { type: 'UNKNOWN' });
console.log(state); // undefined - state destroyed!
// ✅ GOOD: Always return state in default
function goodReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
return state; // CRITICAL: preserve state for unknown actions
}
}
const state = goodReducer({ count: 5 }, { type: 'UNKNOWN' });
console.log(state); // { count: 5 } - state preserved
Why it matters: Redux dispatches initialization actions and other internal actions. Without default case, state becomes undefined, breaking the entire app.
Quick Quiz
References
- Redux: Reducers — the canonical introduction to the
(state, action) => newStatecontract. - Redux Style Guide: Reducers — official guidance on purity, immutability, and the default-case rule.
- Redux Toolkit:
createSliceandcreateReducer— Immer-backed reducers that let you write mutating-looking code safely. - NgRx:
createReducerandon— typed reducers for Angular feature slices. - Immer — the structural-sharing library RTK and many hand-rolled reducers use to keep updates immutable without endless spreads.
- Dan Abramov — “Writing a Tiny Redux” — builds a reducer-driven store from scratch and shows why purity is load-bearing.