Build Your Own: Redux

We're going to rewrite Redux from scratch. Our final result won't have perfect feature parity with the actual Redux library, but it will serve as a substitute for simple projects!

Introduction

Throughout this tutorial, we'll make use of this simple redux application from the Redux documentation.

This application is very straightforward. It renders a single number and two buttons. One button increments the number by one, and the other decrements it by one.

To start, head to https://glitch.com/edit/#!/oak-pm-react-week-build-your-own-redux and click Remix in the top right corner to fork your own app.

index.js
index.html

const initialState = {
value: 0,
};
function counterReducer(state = initialState, action) {
switch (action.type) {
case "counter/incremented":
return { ...state, value: state.value + 1 };
case "counter/decremented":
return { ...state, value: state.value - 1 };
default:
return state;
}
}
const store = Redux.createStore(counterReducer);
const valueEl = document.getElementById("value");
function render() {
const state = store.getState();
valueEl.innerHTML = state.value.toString();
}
render();
store.subscribe(render);
document.getElementById("increment").addEventListener("click", function () {
store.dispatch({ type: "counter/incremented" });
});
document.getElementById("decrement").addEventListener("click", function () {
store.dispatch({ type: "counter/decremented" });
});

The only way to update the state of a Redux application is with a reducer. Reducers are pure functions that take the current state and an action and produce the next state value. Our reducer considers two different action types, "counter/incremented" and "counter/decremented".

index.js

const initialState = {
value: 0,
};
function counterReducer(state = initialState, action) {
switch (action.type) {
case "counter/incremented":
return { ...state, value: state.value + 1 };
case "counter/decremented":
return { ...state, value: state.value - 1 };
default:
return state;
}
}
const store = Redux.createStore(counterReducer);
const valueEl = document.getElementById("value");
function render() {
const state = store.getState();
valueEl.innerHTML = state.value.toString();
}

In the Redux model, there should only be one store, and it is responsible for keeping track of the current state, updating it with the reducer in response to action dispatches, and notifying subscribers.

index.js

function counterReducer(state = initialState, action) {
switch (action.type) {
case "counter/incremented":
return { ...state, value: state.value + 1 };
case "counter/decremented":
return { ...state, value: state.value - 1 };
default:
return state;
}
}
const store = Redux.createStore(counterReducer);
const valueEl = document.getElementById("value");
function render() {
const state = store.getState();
valueEl.innerHTML = state.value.toString();
}

Next, we define a render function. This function takes our new state and updates the DOM to match it. We use store.subscribe() to register the render function to be called every time the store is updated.

index.js

const store = Redux.createStore(counterReducer);
const valueEl = document.getElementById("value");
function render() {
const state = store.getState();
valueEl.innerHTML = state.value.toString();
}
render();
store.subscribe(render);
document.getElementById("increment").addEventListener("click", function () {
store.dispatch({ type: "counter/incremented" });
});

Finally, we attach some event listeners to our buttons. When each button is clicked, we dispatch the appropriate action to the Redux store, which triggers the next state reduction and a subsequent call to the render function, updating our view.

index.js

render();
store.subscribe(render);
document.getElementById("increment").addEventListener("click", function () {
store.dispatch({ type: "counter/incremented" });
});
document.getElementById("decrement").addEventListener("click", function () {
store.dispatch({ type: "counter/decremented" });
});

Getting started

Let's identify the API that we need to support in our new Redux implementation, Reflux.

To start, we'll need to at least support createStore, subscribe, and dispatch.

reflux.js
index.html

function createStore(reducer) {
return {
dispatch: () => {
// TODO: implement this!
},
subscribe: () => {
// TODO: implement this!
},
};
}
const Reflux = {
createStore,
};

The bulk of the work that createStore does actually lives in the implementation of dispatch and subscribe. We'll start by implementing dispatch. To do so, we'll need to take a look at the first argument that createStore takes, a reducer function. We'll use our reducer to compute our state, and then to update it each time dispatch is called with an action.

Notice that we dispatch our own action, @@INIT, when createStore is called. We don't expect any user-provided reducers to respond to this action; instead, we use it to get the initial state from our reducer.

reflux.js

function createStore(reducer) {
let state = reducer(undefined, { type: "@@INIT" });
return {
dispatch: (action) => {
state = reducer(state, action);
},
subscribe: () => {
// TODO: implement this!
},
};
}
const Reflux = {
createStore,
};

Now let's implement subscribe. As we said before, subscribe takes a function (called a listener) as an argument, and that function should be called each time the state is updated.

reflux.js

function createStore(reducer) {
let state = reducer(undefined, { type: "@@INIT" });
const listeners = [];
return {
dispatch: (action) => {
state = reducer(state, action);
},
subscribe: (listener) => {
listeners.push(listener);
},
};
}
const Reflux = {
createStore,
};

Now we need to ensure that our listeners are actually called after the state is updated.


function createStore(reducer) {
let state = reducer(undefined, { type: "@@INIT" });
const listeners = [];
return {
dispatch: (action) => {
state = reducer(state, action);
listeners.forEach((listener) => {
listener();
});
},
subscribe: (listener) => {
listeners.push(listener);
},
};
}
const Reflux = {
createStore,
};

Finally, you might have noticed that our listener isn't passed any arguments. Instead, listeners are expected to call store.getState() to obtain the new state. Let's implement this last method.

reflux.js

function createStore(reducer) {
let state = reducer(undefined, { type: "@@INIT" });
const listeners = [];
return {
dispatch: (action) => {
state = reducer(state, action);
listeners.forEach((listener) => {
listener();
});
},
getState: () => state,
subscribe: (listener) => {
listeners.push(listener);
},
};
}
const Reflux = {
createStore,
};

That's it! Now lets go back to our application, and replace our call to Redux.createStore() with a call to Reflux.createStore().

index.js
reflux.js
index.html

const initialState = {
value: 0,
};
function counterReducer(state = initialState, action) {
switch (action.type) {
case "counter/incremented":
return { ...state, value: state.value + 1 };
case "counter/decremented":
return { ...state, value: state.value - 1 };
default:
return state;
}
}
const store = Reflux.createStore(counterReducer);
const valueEl = document.getElementById("value");
function render() {
const state = store.getState();
valueEl.innerHTML = state.value.toString();
}
render();
store.subscribe(render);
document.getElementById("increment").addEventListener("click", function () {
store.dispatch({ type: "counter/incremented" });
});
document.getElementById("decrement").addEventListener("click", function () {
store.dispatch({ type: "counter/decremented" });
});

Diving Deeper

This initial implementation works great (if you haven't, try running it in a real browser!), but it's missing some crucial features from the real Redux. In particular, our implementation lacks any support for middleware, which means we don't have any first-class support for side effects.

Redux allows customization of the store via "enhancers". A store enhancer is a higher-order function that takes a store creator and returns a new, enhanced store creator.

Middleware is a specific type of enhancer that allows consumers to wrap the dispatch method of the store in order to support, for example, logging or network requests. Redux exposes an applyMiddleware function that takes any number of middleware functions and returns a store enhancer function that can be passed to createStore.

Let's start by extending our store to support store enhancers.

reflux.js

function createStore(reducer, enhancer) {
if (enhancer) {
return enhancer(createStore)(reducer);
}
let state = reducer(undefined, { type: "@@INIT" });
const listeners = [];
return {
dispatch: (action) => {
state = reducer(state, action);
listeners.forEach((listener) => {
listener();
});
},
getState: () => state,
subscribe: (listener) => {
listeners.push(listener);
},
};
}
const Reflux = {
createStore,
};

The Redux middleware API is a little tricky to reason about, so it's worth working our way up to it. Let's start with a simple version of applyMiddleware, which only takes one middleware function. We'll define our middleware function signature as ({ getState, dispatch }) => action => any. It takes the getState and dispatch methods from the store, and returns a new dispatch method, which takes an action.

The expectation is that users will write middleware functions that do some work before or after calling dispatch, like logging to the console.

reflux.js

function applyMiddleware(middleware) {
return (createStore) => (reducer) => {
const store = createStore(reducer);
return {
...store,
dispatch: middleware({
getState: store.getState,
dispatch: store.dispatch,
}),
};
};
}
const Reflux = {
applyMiddleware,
createStore,
};

Let's build a middleware for our simple application, so that we can get a feel for this new API we've built. We'll start with a very simple logging middleware, that logs each action that is dispatched, and the resulting updated state.

index.js
reflux.js

function logger({ getState, dispatch }) {
return (action) => {
console.log("will dispatch", action);
const returnValue = dispatch(action);
console.log("state after dispatch", getState());
return returnValue;
};
}
const initialState = {
value: 0,
};
function counterReducer(state = initialState, action) {
switch (action.type) {
case "counter/incremented":
return { ...state, value: state.value + 1 };
case "counter/decremented":
return { ...state, value: state.value - 1 };
default:
return state;
}
}
const store = Reflux.createStore(
counterReducer,
Reflux.applyMiddleware(logger)
);
const valueEl = document.getElementById("value");
function render() {
const state = store.getState();
valueEl.innerHTML = state.value.toString();
}
render();
store.subscribe(render);
document.getElementById("increment").addEventListener("click", function () {
store.dispatch({ type: "counter/incremented" });
});
document.getElementById("decrement").addEventListener("click", function () {
store.dispatch({ type: "counter/decremented" });
});

Now let's add support for multiple middleware functions. To make this easier, let's start out by building a utility function, compose, that can compose any set of single-argument functions.

A note on function composition

You might recognize "function composition" from a high school algebra class (or you might not, not all high schools are the same!). In mathematics, function composition is usually drawn as an infix operator, ∘. The operator is defined like this:

For some pair of functions f and g, g ∘ f produces a new function (we'll call it h), such that h(x) = g(f(x)).

Here's a more concrete example: let's say f(x) = x + 1, and g(x) = x * 2. Then if h = g ∘ f, h(x) = (x + 1) * 2.

With our compose function below, we can define the same set of transformations in code


function f(x) {
return x + 1;
}
function g(x) {
return x * 2;
}
const h = compose(g, f);
h(2); // => (2 + 1) * 2 === 6

reflux.js

function compose(...functions) {
if (functions.length === 0) {
return (arg) => arg;
}
return functions.reduce((a, b) => (arg) => a(b(arg)));
}
function applyMiddleware(middleware) {
return (createStore) => (reducer) => {
const store = createStore(reducer);
return {
...store,
dispatch: middleware({
getState: store.getState,
dispatch: store.dispatch,
}),
};
};
}
const Reflux = {
applyMiddleware,
compose,
createStore,
};

In order to cooperate with this new function composition system, we need to tweak our middleware API a bit. Instead of directly returning a custom dispatch method, we'll add one more layer of indirection: middleware functions will return a function that takes the dispatch method of the next middleware in the list, and then return their own custom dispatch. This way, middleware functions can pass actions "down the line". The last middleware in the list will get the store.dispatch as next, which will call the actual reducers and update the state.

reflux.js
index.js

function compose(...functions) {
if (functions.length === 0) {
return (arg) => arg;
}
return functions.reduce((a, b) => (arg) => a(b(arg)));
}
function applyMiddleware(...middlewares) {
return (createStore) => (reducer) => {
const store = createStore(reducer);
const dispatchChain = middlewares.map((middleware) =>
middleware({ getState: store.getState, dispatch: store.dispatch })
);
const composedMiddleware = compose(...dispatchChain);
const dispatch = composedMiddleware(store.dispatch);
return {
...store,
dispatch,
};
};
}
const Reflux = {
applyMiddleware,
compose,
createStore,
};

There's just one last tweak we need to make. The dispatch method that we pass in the initial arguments to our middleware functions ({ getState, dispatch }) is currently the real dispatch method from our store. This isn't quite right; we want middleware functions to be able to call the composed dispatch method, so that they can start the chain "from the beginning", if they need to.

reflux.js

function compose(...functions) {
if (functions.length === 0) {
return (arg) => arg;
}
return functions.reduce((a, b) => (arg) => a(b(arg)));
}
function applyMiddleware(...middlewares) {
return (createStore) => (reducer) => {
const store = createStore(reducer);
const middlewareApi = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args),
};
const dispatchChain = middlewares.map((middleware) =>
middleware(middlewareApi)
);
const composedMiddleware = compose(...dispatchChain);
const dispatch = composedMiddleware(store.dispatch);
return {
...store,
dispatch,
};
};
}
const Reflux = {
applyMiddleware,
compose,
createStore,
};

Now we can apply multiple middleware functions. We can even write our own side-effect middleware, like Redux Thunk!

index.js
reflux.js

function sideEffects({ getState, dispatch }) {
return (next) => (action) => {
if (typeof action === "function") {
return action(dispatch, getState);
}
return next(action);
};
}
function logger({ getState }) {
return (next) => (action) => {
console.log("will dispatch", action);
const returnValue = next(action);
console.log("state after dispatch", getState());
return returnValue;
};
}
const initialState = {
value: 0,
};
function counterReducer(state = initialState, action) {
switch (action.type) {
case "counter/incremented":
return { ...state, value: state.value + 1 };
case "counter/decremented":
return { ...state, value: state.value - 1 };
default:
return state;
}
}
const store = Reflux.createStore(
counterReducer,
Reflux.applyMiddleware(sideEffects, logger)
);
const valueEl = document.getElementById("value");
function render() {
const state = store.getState();
valueEl.innerHTML = state.value.toString();
}
render();
store.subscribe(render);
document.getElementById("increment").addEventListener("click", function () {
store.dispatch({ type: "counter/incremented" });
});
document.getElementById("decrement").addEventListener("click", function () {
store.dispatch({ type: "counter/decremented" });
});

Conclusion

And that's... it! We've actually reproduced the vast majority of Redux functionality, and even wrote some useful middleware. There are only a few things missing to reach full feature parity: