Redux
Redux keeps app state in one store that changes only through dispatched actions and pure reducers. Follow one action around the data-flow loop, then see how middleware like thunks handle async work.
🎯 What you'll learn
- The problem Redux solves
- Store, actions and reducers
- Why reducers can't do async; middleware and thunks
- The purity requirement and why Redux Toolkit exists
📚 Before you start
1The Problem Redux Solves
React's built-in state (useState, useContext) works well for state that's local to a component or shared within a small subtree. As an app grows, state that many unrelated components need to read and update (the logged-in user, a shopping cart, notification state) becomes awkward to thread through props or juggle across multiple Context providers. Redux is a state-management library built on one strict rule: all shared application state lives in a single store, and the only way to change it is by dispatching a plain object describing what happened.
Component-local state → useState, stays inside one component's tree
Redux state → ONE store, readable/writable from anywhere, changes only via dispatch2The Store: a Single Source of Truth
import { createStore } from "redux";
interface AppState {
count: number;
}
const initialState: AppState = { count: 0 };
function counterReducer(state = initialState, action: { type: string }): AppState {
switch (action.type) {
case "INCREMENT":
return { count: state.count + 1 };
case "DECREMENT":
return { count: state.count - 1 };
default:
return state;
}
}
const store = createStore(counterReducer);
store.subscribe(() => console.log("state changed:", store.getState()));
store.dispatch({ type: "INCREMENT" }); // logs: state changed: { count: 1 }There is exactly one store per app, holding the entire shared state tree. store.getState() reads it, store.dispatch(action) is the only way to request a change, and store.subscribe(listener) registers a callback fired after every dispatch — this triple (getState/dispatch/subscribe) is the entire public surface a reducer-driven UI is built on.
3Actions: Plain Objects Describing "What Happened"
// an action is JUST a plain object with a `type` field — nothing more required
const increment = { type: "INCREMENT" };
const addTodo = { type: "todos/add", payload: { text: "Buy milk" } };
// an "action creator" is just a function that returns one
function addTodoAction(text: string) {
return { type: "todos/add", payload: { text } };
}An action describes an event that already happened ("the user clicked increment," "a todo was added"), not a command to mutate state directly — the reducer is what decides how that event changes state. By convention, type is a string (often namespaced like "todos/add") and any additional data rides in a payload field, though Redux itself enforces nothing about the shape beyond requiring a type.
4Reducers: Pure Functions From (state, action) to New State
function todosReducer(state: Todo[] = [], action: { type: string; payload?: any }): Todo[] {
switch (action.type) {
case "todos/add":
return [...state, { id: Date.now(), text: action.payload.text, done: false }];
case "todos/toggle":
return state.map((t) => (t.id === action.payload.id ? { ...t, done: !t.done } : t));
case "todos/remove":
return state.filter((t) => t.id !== action.payload.id);
default:
return state; // MUST return the existing state for unrecognized actions
}
}A reducer takes the current state and an action, and returns the next state — never mutating the state it was given. Every branch that doesn't apply returns the original state reference unchanged (the default case), which is what lets Redux (and React-Redux's useSelector, covered next) cheaply detect "did this part of the tree actually change" via reference equality rather than deep comparison.
5Combining Reducers for a Larger State Tree
import { combineReducers, createStore } from "redux";
const rootReducer = combineReducers({
counter: counterReducer,
todos: todosReducer,
});
const store = createStore(rootReducer);
// store.getState() now looks like: { counter: { count: 0 }, todos: [] }combineReducers builds one root reducer out of many smaller ones, each responsible for its own slice of the overall state tree, keyed by the object property name it's assigned to. Each slice reducer only ever sees and returns its own slice — todosReducer has no idea counter exists — which is what lets a large app's state management stay organized as many small, independently testable reducers instead of one giant function.
6Connecting to React: useSelector and useDispatch
import { Provider, useSelector, useDispatch } from "react-redux";
function Counter() {
const count = useSelector((state: RootState) => state.counter.count);
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
</div>
);
}
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}react-redux (a separate package from redux itself) supplies the React bindings: <Provider store={store}> makes the store available via context to the whole tree, useSelector subscribes a component to a specific slice of state (re-rendering only when the selected value changes, by reference equality — this is exactly why reducers must return new references only when data actually changed), and useDispatch returns the store's dispatch function for firing actions from event handlers.
⚠️ Common Pitfalls
- Mutating state directly inside a reducer (
state.count++orstate.push(item)) instead of returning a new object/array — breaks the reference-equality change detectionuseSelectorand Redux's own internals depend on, causing silent "state changed but nothing re-rendered" bugs. - Forgetting the
default: return statecase — an unhandled action type falling through with no return producesundefinedas the new state, corrupting that slice. - Doing async work (an API call) directly inside a reducer — reducers must be pure and synchronous; async logic belongs in middleware (Part 2) or, in modern Redux Toolkit code,
createAsyncThunk. - Selecting more state than needed in
useSelector(e.g. the whole state tree instead of one slice) — causes the component to re-render on every unrelated state change, defeating the fine-grained subscription model. - Putting state that's genuinely local to one component into Redux — not everything needs to be global; a form input's in-progress value rarely needs to live in the shared store.
📋 Quick Reference
createStore(reducer) → one store, holds the whole state tree
store.getState() / .dispatch(action) / .subscribe(fn) → the entire store API
action = { type: string, payload?: any } → plain object, describes what happened
reducer = (state, action) => newState → pure function, NEVER mutates, always
returns unchanged state for unknown actions
combineReducers({ slice1: r1, slice2: r2 }) → one root reducer from many slice reducers
<Provider store={store}> → makes the store available via context
useSelector((state) => state.slice.value) → subscribe to one slice, re-render on change
useDispatch() → returns dispatch, used in event handlersPart 2 covers middleware (why reducers alone can't handle async), the purity requirement in more depth, and why Redux Toolkit exists.
1Why Plain Reducers Can't Do Async
Part 1's rule was absolute: reducers are pure, synchronous functions of (state, action) => newState. But real apps need to fetch data from a server, and an API call is neither pure (its result depends on network state, not just its arguments) nor synchronous (it returns a Promise). Redux's answer isn't "bend the reducer rule" — it's middleware: a layer that intercepts every dispatched action before it reaches any reducer, able to pause, inspect, delay, or dispatch further actions of its own.
dispatch(action) → [middleware 1] → [middleware 2] → ... → reducer → new state2What Middleware Actually Is
import { createStore, applyMiddleware } from "redux";
const loggerMiddleware = (store: any) => (next: any) => (action: any) => {
console.log("dispatching:", action);
const result = next(action); // pass the action along the chain
console.log("next state:", store.getState());
return result;
};
const store = createStore(rootReducer, applyMiddleware(loggerMiddleware));A middleware is a function with the signature store => next => action => result — a triple-nested arrow function. next is either the next middleware in the chain or, for the last one, the store's real dispatch that hands the action to the reducers. Calling next(action) continues the chain; NOT calling it stops the action from ever reaching the reducer at all — this is what lets a middleware swallow an action, delay it, or dispatch a completely different one instead.
3Thunks: the Standard Async Middleware
// with redux-thunk middleware applied, dispatch can accept a FUNCTION,
// not just a plain action object
function fetchUser(userId: string) {
return async (dispatch: any, getState: any) => {
dispatch({ type: "user/loading" });
try {
const res = await fetch(`/api/users/${userId}`);
const user = await res.json();
dispatch({ type: "user/loaded", payload: user });
} catch (err) {
dispatch({ type: "user/error", payload: String(err) });
}
};
}
dispatch(fetchUser("42")); // thunk middleware intercepts the FUNCTION and calls itRedux-thunk's middleware checks whether a dispatched "action" is actually a function; if so, it calls that function with (dispatch, getState) instead of passing it to reducers, letting the function dispatch as many real actions as it wants, whenever it wants (including after an await). This is how async logic — loading flags, success/error branching, sequencing multiple dispatches — lives outside the reducer entirely while still only ever changing state through ordinary, pure reducer-handled actions.
4The Purity Requirement, Restated Precisely
// WRONG — impure: depends on Date.now() (external, non-deterministic input)
function reducer(state: Todo[], action: any) {
if (action.type === "add") {
return [...state, { id: Date.now(), text: action.payload }]; // breaks purity
}
return state;
}
// RIGHT — the id is generated OUTSIDE the reducer, passed in via the action
function addTodo(text: string) {
return { type: "add", payload: { id: crypto.randomUUID(), text } };
}A pure reducer must produce the exact same output every time it's called with the exact same (state, action) pair — no Date.now(), Math.random(), network calls, or reading global mutable variables inside the reducer body. Purity is what makes Redux's state predictable and replayable: given a sequence of actions, you can always recompute the exact same final state, which is the foundation both of Redux DevTools' time-travel debugging and of reliably testing a reducer as "call it, assert the output" with zero mocking.
5Why Redux Toolkit Exists
Writing "plain" Redux at scale accumulates real boilerplate: a constants file for action type strings, an action-creator function for each action type, a switch-statement reducer per slice, manual immutable-update spreading for nested state, and manually wiring applyMiddleware(thunk) plus Redux DevTools. None of this is wrong, but it's a lot of ceremony for what's conceptually simple:
// plain Redux: type constant + action creator + switch-case reducer branch,
// three separate places to touch for ONE new capability
const TOGGLE_TODO = "todos/toggle";
function toggleTodo(id: string) { return { type: TOGGLE_TODO, payload: id }; }
// ...then a case TOGGLE_TODO: branch inside the reducer's switch...Redux Toolkit (RTK), covered in the next topic, is the officially recommended way to write Redux today — createSlice generates the action creators and the reducer from one definition, configureStore wires up thunk middleware and DevTools automatically, and an Immer-powered "mutating" syntax lets reducer logic read like direct mutation while Immer produces the correct immutable update underneath. Nothing about Redux's underlying model (single store, dispatched actions, pure reducers) changes — RTK is a productivity layer on top of the exact same mental model this topic just covered.
Under the HoodHow it works internally▾
applyMiddleware builds a middleware chain by literally composing functions around the store's real dispatch. Each middleware in the array you pass receives a reference to store (for getState/re-dispatching) and to next, which is either the next middleware's handler or, for the outermost call, the store's actual internal dispatch that runs the reducers and notifies subscribers. applyMiddleware wires this up via function composition (conceptually middleware1(middleware2(middleware3(realDispatch)))), which is why middleware order matters — logging middleware placed before thunk middleware in the array logs the raw function/thunk being dispatched (unhelpful), while placed after it only ever sees plain action objects that already passed through thunk's interception.
Redux's subscribe/notification model is a flat list of listeners, fired synchronously after every dispatch completes. There's no batching or scheduling built into plain Redux itself — calling dispatch runs the entire reducer tree synchronously, updates the internal state reference, and then calls every subscribed listener in registration order, all before dispatch() returns. react-redux's useSelector is one such subscriber per connected component; it re-runs your selector function on every dispatch and compares the result via Object.is to the previous value, only triggering a React re-render when that comparison fails — which is the concrete mechanism behind Part 1's warning that reducers must return new references only when data actually changed.
⚠️ Common Pitfalls
- Forgetting to apply thunk middleware and then dispatching a function — without
applyMiddleware(thunk)(or RTK'sconfigureStore, which includes it by default),dispatch(someFunction)throws, because plain Redux's dispatch only accepts plain action objects. - Putting side effects (API calls,
localStoragewrites) directly in a reducer instead of a thunk/middleware — violates purity and makes the reducer's behavior depend on things outside its arguments. - Middleware order mistakes — placing logging middleware before thunk middleware in the
applyMiddlewarecall logs unresolved thunk functions instead of the real actions they eventually dispatch. - Assuming DevTools time-travel "just works" with impure reducers — if a reducer reads
Date.now()/Math.random()/external mutable state, replaying the same action sequence from a saved point produces a different result than what actually happened, breaking the entire replay guarantee. - Writing large amounts of new plain-Redux boilerplate in a 2020s codebase — the official Redux docs themselves recommend Redux Toolkit as the default starting point; hand-writing action-type constants and switch-statement reducers is now considered the "no longer necessary" pattern outside of maintaining older code or teaching the fundamentals (as this topic does).
📋 Quick Reference
middleware signature: store => next => action => result
applyMiddleware(m1, m2, ...) → composes them around the store's real dispatch, IN ORDER
thunk: dispatch(fn) where fn = (dispatch, getState) => { ... }
middleware detects a function was dispatched, calls it instead of passing to reducers
Purity rule: reducer(state, action) must be deterministic — no Date.now(), Math.random(),
network calls, or external mutable reads inside the reducer body
Notification: dispatch runs reducers synchronously, THEN calls every subscriber
useSelector: re-runs selector after each dispatch, re-renders only if Object.is() differs
Why Redux Toolkit exists: createSlice + configureStore remove the boilerplate
(action-type constants, hand-written action creators, switch-statement reducers,
manual middleware/DevTools wiring) without changing the underlying model at all.🧠 Key takeaways
- One store; state changes only via dispatched actions handled by pure reducers.
- Unidirectional flow makes changes predictable and debuggable (DevTools time travel).
- Async and side effects live in middleware (thunks, sagas, listeners), never in reducers.
- Plain Redux is verbose; Redux Toolkit is the recommended way to write Redux today.
🎤 Interview Q&A
Q1Explain Redux's core principles.core▾
A single store holding app state, state is read-only and changed only by dispatching actions (plain objects describing what happened), and reducers are pure functions computing the next state. This makes updates predictable and traceable.
- What's the data flow? UI dispatches action → reducer → new state → subscribed UI re-renders.
Q2What is middleware in Redux?core▾
Functions wrapping dispatch to handle side effects or cross-cutting logic: async (thunks, sagas, observables), logging, analytics, error reporting.
- Thunk vs saga? Thunks are simple async functions; sagas use generators for complex flows.
Q3Why use selectors and memoization?core▾
Selectors encapsulate how to read state; memoized selectors (Reselect) avoid recomputing derived data and keep returned references stable so components don't re-render unnecessarily.
- Bug from unmemoized selectors? Returning new arrays each time causes re-renders.
Q4When is Redux overkill?senior▾
Small apps, mostly server state (use TanStack Query/RTK Query), or state that's local to components. Redux shines with complex shared client state, many contributors, and a need for devtools/time-travel debugging.
- What's modern Redux? Redux Toolkit; hand-written Redux is legacy.
Q5How should Redux state be shaped?core▾
Normalized like a database (entities by ID plus ID lists), minimal (derive the rest), serializable (no class instances, promises), and organized by feature slices.
- Why serializable? Devtools, persistence and time-travel need it.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.