Write a Redux-style createStore(reducer, initialState) with getState, dispatch, and subscribe.
Before you start
What's a Redux store, and what's a reducer?
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.
createStore(reducer, initialState) returns a store with three methods. The store starts with initialState, then dispatches { type: "@@INIT" } once, so a reducer's default state applies when initialState is undefined.
getState() returns the current state.
dispatch(action) replaces the state with reducer(state, action), calls every subscriber, and returns action. A reducer that calls dispatch must get an Error, and the store must keep working after that.
subscribe(listener) adds a subscriber and returns an unsubscribe function that removes it. Subscribing or unsubscribing during a dispatch takes effect from the next dispatch.
function createStore(reducer, initialState) {
// your code
}
// const counter = (state = 0, action) => (action.type === "increment" ? state + 1 : state);
// const store = createStore(counter);
// store.getState(); // 0: the reducer's default, from the @@INIT dispatch
// const unsubscribe = store.subscribe(() => console.log(store.getState()));
// store.dispatch({ type: "increment" }); // logs 1
// unsubscribe();
// store.dispatch({ type: "increment" }); // logs nothing
// store.getState(); // 2
Solve it in the editor, with tests
The answer
Write it yourself first. Then compare it with a reference solution and the checks it passes.
Checking your sign-in…