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.
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"
.
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.
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.
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.
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
.
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.
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.
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.
That's it! Now lets go back to our application, and replace our call to
Redux.createStore()
with a call to Reflux.createStore()
.
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.
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.
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.
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
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.
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.
Now we can apply multiple middleware functions. We can even write our own side-effect middleware, like Redux Thunk!
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:
- Redux does a lot of argument type checking and logging that we simply skip
- Redux supports optionally passing a
preloadedState
object, which is especially useful when hydrating your initial state from a server, or restoring a user session. - Redux also exports a
combineReducers
convenience function for composing "state slice reducers" into a singled root reducer. Try to implement it yourself! - The Redux Store object also exposes a
replaceReducer
method, which allows you to "hot swap" your store reducer. This might be useful if you need to load reducers dynamically, or for hot reloading in development.