Redux is a library that keeps an app's state, the data the screen is drawn from, in one place called the store. You can read the state whenever you like, but you can't change it directly. To change it, you dispatch an action: a plain object that describes what happened, with a type saying what kind of thing it was, such as { type: "added", amount: 5 }. The store hands its current state and the action to a function called the reducer, keeps whatever the reducer returns as the new state, and then tells everyone who subscribed. A Redux-style store is any store that works this way, through three methods:
| Method | What it does |
|---|---|
store.getState() | Returns the current state. |
store.dispatch(action) | Calls the reducer with the current state and action, keeps the result as the new state, then calls every subscriber. It returns action. |
store.subscribe(listener) | Adds a function to call after every dispatch, and returns a function that removes it. Subscribers get no arguments: they call getState() to see what's new. |
A reducer is a pure function, (state, action) => nextState. Pure means its result depends only on its arguments, and it has no side effects: no requests, no timers, no random numbers, and no changes to the objects it was given. It looks at the action's type, works out what the state should be afterwards, and returns that. The name comes from Array.prototype.reduce: a reducer is the kind of function you'd pass to reduce, turning a list of actions into one state.
const counter = (state = 0, action) => {
if (action.type === "incremented") return state + 1;
if (action.type === "added") return state + action.amount;
return state;
};
counter(undefined, { type: "start" }); // 0: the default parameter fills in
counter(0, { type: "incremented" }); // 1
counter(1, { type: "added", amount: 5 }); // 6
counter(6, { type: "todoAdded" }); // 6: not its business, so the state comes back as it was
const actions = [{ type: "incremented" }, { type: "added", amount: 5 }];
actions.reduce(counter, undefined); // 6, as if a new store had been sent both actions
Why state = 0? A new store has no state yet, so before any action of yours it calls the reducer once, with state set to undefined and an action no reducer handles. The default parameter turns that undefined into the starting state, so each reducer declares its own starting value, right next to the code that changes it. If you give the store a starting state, the reducer gets that instead and the default isn't used. Defaults only replace undefined: null is a real state, and a reducer given null keeps it. Here's a store made with counter, step by step:
| Step | The reducer gets | The state is now | Subscribers |
|---|---|---|---|
| The store is created | undefined and the store's own first action | 0, the default | None yet: nobody can subscribe before the store exists. |
dispatch({ type: "incremented" }) | 0 and that action | 1 | All called; getState() gives 1. |
dispatch({ type: "added", amount: 5 }) | 1 and that action | 6 | All called; getState() gives 6. |
dispatch({ type: "todoAdded" }) | 6 and that action | 6, the same value | All called, and they can tell that nothing changed. |
Two rules keep this working. A reducer never changes state in place: to add a todo, it returns a new array, [...state, todo], instead of calling state.push(todo). And when an action doesn't concern it, it returns the state it was given, the very same object. Code that watches a store often decides whether to redraw by comparing old and new values with ===, as React Redux's useSelector does by default. A different object means something changed; the same object means nothing did. An object changed in place is still === to itself, so the change goes unnoticed, and a copy made when nothing changed makes everyone redraw for nothing.
In a real app the state is usually an object with one key per feature, such as { todos, filter }, and each key has its own small reducer that only ever sees its part. Redux's combineReducers joins them into the one reducer a store takes. You meet reducers in Redux and Redux Toolkit, in NgRx for Angular, and in React's useReducer hook, which keeps one component's state with a reducer and gives it a dispatch of its own. Redux Toolkit's createSlice lets you write reducers that look as if they change state; it uses the Immer library to turn those changes into a new object.
Write createStore(reducer, initialState), the core of Redux. It returns a store object with three methods.
getState() returns the current state.
dispatch(action) sets the state to reducer(state, action), then calls every subscriber with no arguments, and returns action.
subscribe(listener) adds a subscriber and returns a function that removes it.
When the store is created, it dispatches { type: "@@INIT" } once, so a reducer's default state applies when initialState is undefined. Nobody can have subscribed yet, so that dispatch only sets the state.
Subscribing or unsubscribing during a dispatch takes effect from the next dispatch. A reducer that calls dispatch gets an Error thrown at it, and the store keeps working afterwards.
const counter = (state = 0, action) => (action.type === "increment" ? state + 1 : state);
const store = createStore(counter);
store.getState(); // 0
const unsubscribe = store.subscribe(() => console.log("now", store.getState()));
store.dispatch({ type: "increment" }); // logs "now 1"
unsubscribe();
store.dispatch({ type: "increment" }); // logs nothing; the state is 2
const counter = (state = 0, action) => (action.type === "inc" ? state + 1 : state);
const store = createStore(counter);
expect(store.getState()).toBe(0);
const store = createStore((state = 0) => state, 5);
expect(store.getState()).toBe(5);
const reducer = spy((state = 0) => state);
createStore(reducer);
expect(reducer).toHaveBeenCalledTimes(1);
expect(reducer).toHaveBeenCalledWith(undefined, { type: "@@INIT" });
const reducer = spy((state = 0, action) => (action.type === "add" ? state + action.by : state));
const store = createStore(reducer);
store.dispatch({ type: "add", by: 2 });
store.dispatch({ type: "add", by: 3 });
expect(reducer).toHaveBeenLastCalledWith(2, { type: "add", by: 3 });
expect(store.getState()).toBe(5);
const store = createStore((state = 0) => state);
const action = { type: "x" };
expect(store.dispatch(action)).toBe(action);
const store = createStore((state = 0, action) => state + (action.by ?? 0));
const seen = [];
const unsubscribe = store.subscribe(() => seen.push(store.getState()));
store.dispatch({ type: "add", by: 2 });
unsubscribe();
store.dispatch({ type: "add", by: 3 });
expect(seen).toEqual([2]);
expect(store.getState()).toBe(5);
const store = createStore((state = 0) => state + 1);
const late = spy();
store.subscribe(() => store.subscribe(late));
store.dispatch({ type: "x" });
expect(late).not.toHaveBeenCalled();
store.dispatch({ type: "x" });
expect(late).toHaveBeenCalledTimes(1);
const store = createStore((state = 0) => state + 1);
const log = [];
let unsubscribeB;
store.subscribe(() => {
log.push("a");
unsubscribeB();
});
unsubscribeB = store.subscribe(() => log.push("b"));
store.subscribe(() => log.push("c"));
store.dispatch({ type: "x" });
store.dispatch({ type: "x" });
expect(log).toEqual(["a", "b", "c", "a", "c"]);
let store;
const reducer = (state = 0, action) => {
if (action.type === "nested") store.dispatch({ type: "inc" });
return action.type === "inc" ? state + 1 : state;
};
store = createStore(reducer);
expect(() => store.dispatch({ type: "nested" })).toThrow(Error);
expect(store.getState()).toBe(0);
store.dispatch({ type: "inc" });
expect(store.getState()).toBe(1);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Redux-style store. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.