Ever wondered how Redux works? Here's how you can implement your own!
Redux has become the defacto standard for state management in React. It's a great tool for handling global state, and its sheer popularity means you'll probably want to learn it at some point.
Redux isn't the easiest concept to learn though. Even though the docs are good (and are being rewritten to be even better), it's often hard to understand the concept of Redux' uni-directional data flow, dispatching, reducing, actions and what have you. I struggled with it myself, when I first came across Redux.
Luckily for us, Redux isn't as complicated as it looks. As a matter of fact, you can implement a working version of the core parts or Redux in 27 lines of code!
This article will take you through how you can implement an API similar to Redux yourself. Not because you'll want to do just that, but because it'll help you understand how Redux works!
The core part of Redux the store. This store contains a single state tree. The store lets you read the state, dispatch actions to update the state, subscribe and unsubscribe for updates to that state, that's about it.
This store is passed around your application. If you're using React, you're probably passing your store to react-redux
's <Provider />
component, which lets you access it in other parts of your application by wrapping your component with connect()
.
We're going to re-implement Redux by implementing the createStore
method. It does what it says on the tin - it gives us a store instance we can play with. The store is just an object with a few methods on it, so it doesn't need to be fancy.
Let's start off small, by implementing the getState
method:
function createStore() {
let state = {};
return {
getState() {
return state;
}
};
}
When we call createStore
, we create an empty state object. This is that single state tree you keep hearing about. We return our "store", which is just an object with one property - a getState
function. Calling this getState
function grants access to the state
variable inside of the createStore
closure.
This is how we'd use it:
import { createStore } from './redux';
const store = createStore();
const state = store.getState();
One of the core concepts of Redux is the reducer. A Redux reducer is a function that accepts the current state and an action, and returns the next state (the state after an action has happened). Here's a simple example:
function countReducer(state = 0, action) {
if (action.type === 'INCREMENT') return state + 1;
if (action.type === 'DECREMENT') return state - 1;
return state;
}
Here - the countReducer
responds to two actions - INCREMENT
and DECREMENT
. If the action passed doesn't match either, the current state is returned.
To continue our journey in understanding Redux, we need to take a quick break, and understand the data flow of Redux:
In order for us to follow this flow, we need our store to have a reducer! Let's pass that in as the first argument:
function createStore(initialReducer) {
let reducer = initialReducer;
let state = reducer({}, { type: '__INIT__' });
return {
getState() {
return state;
}
};
}
Here, we accept a reducer, and call it to get our initial state. We "trigger" an initial action, and pass in an empty object to our state.
Redux actually lets us pass in pre-calculated state when we create our store. This might have been persisted in local storage, or come from the server side. Anyhow, adding support for it is as simple as passing an initialState
argument to our createStore
function:
function createStore(initialReducer, initialState = {}) {
let reducer = initialReducer;
let state = reducer(initialState, { type: '__INIT__' });
return {
getState() {
return state;
}
};
}
Great! Now we even support server side rendering - that's pretty neat!
The next step in our Redux journey is to give the user some way to say that something happened in our app. Redux solves this by giving us a dispatch
function, which lets us call our reducer with an action.
function createStore(initialReducer, initialState = {}) {
let reducer = initialReducer;
let state = reducer(initialState, { type: '__INIT__' });
return {
getState() {
return state;
},
dispatch(action) {
state = reducer(state, action);
}
};
}
As we can tell from the implementation, the concept of "dispatching" an action is just calling our reducer function with the current state and the action we passed. That looks pretty simple!
Changing the state isn't worth much if we have no idea when it happens. That's why Redux implements a simple subscription model. You can call the store.subscribe
function, and pass in a handler for when the state changes - like this:
const store = createStore(reducer);
store.subscribe(() => console.log('The state changed! 💥', store.getState()));
Let's implement this:
function createStore(initialReducer, initialState = {}) {
let reducer = initialReducer;
let subscribers = [];
let state = reducer(initialState, { type: '__INIT__' });
return {
getState() {
return state;
},
dispatch(action) {
state = reducer(state, action);
subscribers.forEach(subscriber => subscriber());
},
subscribe(listener) {
subscribers.push(listener);
}
};
}
We create an array of subscribers, which starts out as empty. Whenever we call our subscribe
function, the listener is added to the list. Finally - when we dispatch an action, we call all subscribers to notify them that the state has changed.
Redux also lets us unsubscribe from listening to state updates. Whenever you call the subscribe
function, an unsubscribe function is returned. When you want to unsubscribe, you would call that function. We can augment our subscribe
method to return this unsubscribe
function:
function createStore(initialReducer, initialState = {}) {
let reducer = initialReducer;
let subscribers = [];
let state = reducer(initialState, { type: '__INIT__' });
return {
getState() {
return state;
},
dispatch(action) {
state = reducer(state, action);
subscribers.forEach(subscriber => subscriber());
},
subscribe(listener) {
subscribers.push(listener);
return () => {
subscribers = subscribers.filter(subscriber => subscriber !== listener);
};
}
};
}
The unsubscribe
function removes the subscriber from the internal subscriber-registry array. Simple as that.
If you're loading parts of your application dynamically, you might need to update your reducer function. It's not a very common use-case, but since it's the last part of the store API, let's implement support for it anyways:
function createStore(initialReducer, initialState = {}) {
let reducer = initialReducer;
let subscribers = [];
let state = reducer(initialState, { type: '__INIT__' });
return {
getState() {
return state;
},
dispatch(action) {
state = reducer(state, action);
subscribers.forEach(subscriber => subscriber(state));
},
subscribe(listener) {
subscribers.push(listener);
return () => {
subscribers = subscribers.filter(subscriber => subscriber !== listener);
};
},
replaceReducer(newReducer) {
reducer = newReducer;
this.dispatch({ type: '__REPLACE__' });
}
};
}
Here we simply swap the old reducer with the new reducer, and dispatch an action to re-create the state with the new reducer, in case our application needs to do something special in response.
We've actually left out a pretty important part of our implementation - store enhancers. A store enhancer is a function that accepts our createStore
function, and returns an augmented version of it. Redux only ships with a single enhancer, namely applyMiddleware
, which lets us use the concept of "middleware" - functions that let us do stuff before and after the dispatch
method is called.
Implementing support for store enhancers is 3 lines of code. If one is passed - call it and return the result of calling it again!
function createStore(initialReducer, initialState = {}, enhancer) {
if (enhancer) {
return enhancer(createStore)(initialReducer, initialState);
}
let reducer = initialReducer;
let subscribers = [];
let state = reducer(initialState, { type: '__INIT__' });
return {
getState() {
return state;
},
dispatch(action) {
state = reducer(state, action);
subscribers.forEach(subscriber => subscriber(state));
},
subscribe(listener) {
subscribers.push(listener);
return () => {
subscribers = subscribers.filter(subscriber => subscriber !== listener);
};
},
replaceReducer(newReducer) {
reducer = newReducer;
this.dispatch({ type: '__REPLACE__' });
}
};
}
That's it! You've successfully re-created the core parts of Redux! You can probably drop these 27 lines into your current app, and find it working exactly as it is already.
Now, you probably shouldn't do that, because the way Redux is implemented gives you a ton of safeguards, warnings and speed optimizations over the implementation above - but it gives you the same features!
If you want to learn more about how Redux actually works, I suggest you have a look at the actual source code. You'll be amazed at how similar it is to what we just wrote.
There isn't really any point in re-implementing Redux yourself. It's a fun party trick, at best. However, seeing how little magic it really is will hopefully improve your understanding of how Redux works! It's not a mysterious black box after all - it's just a few simple methods and a subscription model.
I hope this article has solidified your knowledge on Redux and how it works behind the scenes. Please let me know in the comments if you still have questions, and I'll do my best to answer them!
All rights reserved © 2024