Reducer (Saga template)
What is a Reducer?
A reducer is a pure function (state, action) → next state. It's the only piece of code in the app allowed to describe state changes; everything else (UI, sagas, selectors) only reads. In the saga template each domain slice has its own reducer; combineReducers glues them into the root reducer.
Key Principles
Initial state lives in its own file.
<slice>.initial.jsexportsintial<Slice>State(note the legacy spelling without the 'i' — preserved for the chota-react-saga template's import surface). Reducer tests import it directly.Three cases per async op, one per sync op. Every async CRUD verb owns a triple of
case <OP>_<ENTITY>,case <OP>_<ENTITY>_SUCCESS,case <OP>_<ENTITY>_ERROR. Sync ops collapse to one case.isLoading,isContentLoading,isActionLoading. Three independent flags.isContentLoadingis set during READ (full reload).isActionLoadingis set during CREATE/UPDATE. The widerisLoadingflag covers both. The UI picks which one to render against.Optimistic on the REQUEST, rollback on the ERROR. For toggle/delete, the REQUEST case mutates the items list AND captures
previousState<Items>: [...state.<items>]so the saga can read it on failure. The fall-throughcase TOGGLE_X_SUCCESS: case DELETE_X_SUCCESS:clearspreviousState<Items>toundefined.Spread for immutability. Never mutate
state; always{ ...state, … }. Same for nested objects. The reducer is the source of truth for that invariant.Default branch returns current state. No
throw, noconsole.warn. Unknown actions are an expected condition (other slices' actions reach this reducer too).
Best Practices
✅ DO:
- Import
<slice>.initial.jsasintial<Slice>State— the legacy spelling matches what other files export. - Import the helper transforms (e.g.
toggleCheckedState) from<slice>.helper.js; the helper is shared with the saga. - Keep
let <items> = []declared at the top of the function when the reducer rebuilds the list (update/toggle/delete) to avoidconstrebinding noise. - Group cases in the order READ, CREATE, EDIT, UPDATE, TOGGLE, DELETE; group SUCCESS/ERROR right after their REQUEST.
❌ DON'T:
- Don't dispatch from a reducer (mutating
dispatchfrom inside violates purity). - Don't read
Date.now(),Math.random(), orwindow.*from a reducer. Side effects belong to the saga. - Don't keep entity ids in
currentTodoItemafter a successful UPDATE — clear it back tointial<Slice>State.currentTodoItemso the form resets.
Code Patterns
Initial state (<slice>.initial.js)
const intialTodoState = {
isLoading: false,
isActionLoading: false,
isContentLoading: false,
error: '',
todoItems: [],
currentTodoItem: { text: '', id: '' }
};
export default intialTodoState;
Reducer (<slice>.reducer.js)
import { toggleCheckedState } from "./todo.helper";
import intialTodoState from "./todo.initial";
import {
CREATE_TODO, CREATE_TODO_SUCCESS, CREATE_TODO_ERROR,
READ_TODO, READ_TODO_SUCCESS, READ_TODO_ERROR,
EDIT_TODO,
UPDATE_TODO, UPDATE_TODO_SUCCESS, UPDATE_TODO_ERROR,
TOGGLE_TODO, TOGGLE_TODO_SUCCESS, TOGGLE_TODO_ERROR,
DELETE_TODO, DELETE_TODO_SUCCESS, DELETE_TODO_ERROR,
} from "./todo.type";
const todo = (state = intialTodoState, action) => {
let todoItems = [];
switch (action.type) {
// ── Standard Content Loading ────────────────────────────────────────
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 };
// ── Standard Action Modification ────────────────────────────────────
case CREATE_TODO:
return { ...state, isLoading: true, isActionLoading: true, currentTodoItem: action.payload };
case CREATE_TODO_SUCCESS:
return {
...state,
isLoading: false, isActionLoading: false,
todoItems: [...state.todoItems, {
id: action.payload.id,
text: action.payload.text,
completed: false,
}],
currentTodoItem: intialTodoState.currentTodoItem,
};
case CREATE_TODO_ERROR:
return {
...state,
isLoading: false, isActionLoading: false,
error: action.error,
currentTodoItem: intialTodoState.currentTodoItem,
};
// ── Selected Entity (sync stage step) ───────────────────────────────
case EDIT_TODO:
return { ...state, currentTodoItem: action.payload };
// ── Partial Action Modification ─────────────────────────────────────
case UPDATE_TODO:
todoItems = state.todoItems.map((todo) =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text }
: todo
);
return { ...state, isActionLoading: true, todoItems, currentTodoItem: action.payload };
case UPDATE_TODO_SUCCESS:
return { ...state, isActionLoading: false, currentTodoItem: intialTodoState.currentTodoItem };
case UPDATE_TODO_ERROR:
return { ...state, error: action.error, isActionLoading: false, currentTodoItem: intialTodoState.currentTodoItem };
// ── Parallel Action Modification (optimistic) ───────────────────────
case TOGGLE_TODO:
todoItems = state.todoItems.map((todo) =>
todo.id === action.payload.id ? toggleCheckedState(todo) : todo
);
return { ...state, previousStateTodoItems: [...state.todoItems], todoItems };
case DELETE_TODO:
todoItems = state.todoItems.filter((todo) => todo.id !== action.payload.id);
return {
...state,
previousStateTodoItems: [...state.todoItems],
todoItems,
currentTodoItem: intialTodoState.currentTodoItem,
};
// toggle and delete share fall-through SUCCESS/ERROR cases:
case TOGGLE_TODO_SUCCESS:
case DELETE_TODO_SUCCESS:
return { ...state, previousStateTodoItems: undefined, isLoading: false };
case TOGGLE_TODO_ERROR:
case DELETE_TODO_ERROR:
return {
...state,
previousStateTodoItems: undefined,
isLoading: false,
error: action.error,
todoItems: action.payload, // saga sends the rollback list as payload
};
default:
return state;
}
};
export default todo;
Related Terminologies
- Actions — every type matched in
switchis exported from<slice>.type.js. - Saga — emits SUCCESS/ERROR actions that this reducer responds to.
- Helper —
<slice>.helper.jsprovidestoggleCheckedState(shared with the saga). - Selectors — derive view-data from this state shape; they're the only consumers downstream.
Quality Gates
- Every type imported is matched in a
case; nothingdefault-only. - Optimistic ops set
previousState<Items>on REQUEST. - SUCCESS clears
currentTodoItemandpreviousState<Items>. - No
await, nodispatch, noMath.random(), noDate. -
defaultreturnsstateuntouched.
Source: templates/chota-react-saga/src/state/todo/todo.reducer.js, todo.initial.js