Redux is a state management library used in a lot of projects.
An new library named redux-toolkit
has be developed to reduce boilerplate of redux
. Give it a try it simplifies a lot the code you make, and with typescript <3
To be easily integrated with React, Angular, ... some bindings libraries exist react-redux, ng-redux, ...
But that is not the subject of this article. I will not explain the best practices on how to use Redux. If you want more explanation on how to use it, you can see the
documentation which is awesome: https://redux.js.org/
In this article we are going to see how to implement a redux library like. Don't be afraid, it's not so complicated.
How is the article built?
We are going to pass on each features of redux, a quick view of what is it need for and then the implementation. Features are:
- store
- reducers
- listeners
- observables
- replaceReducers
- middlewares
Let's get in :)
Store creation
Some context
To create a store, you have to use the method createStore
and give it the reducer(s) as first parameter:
import { createStore } from "redux";
import userReducer from "./userReducer";
const store = createStore(userReducer);
With this store created, you can get two methods:
-
getState
to get the current state -
dispatch
to dispatch actions wich will be passed to reducers
store.dispatch({
type: "SET_USERNAME",
payload: "Bob the Sponge",
});
const state = store.getState();
// Will print 'Bob the Sponge'
console.log(state.userName);
Reducers
A reducer is a pure function, it's the only one which can change the state (sometimes called also store). The first parameter of this method is the
current state and the second one the action to handle:
The action is a simple object which is often represented with:
- type: the type of the action to process
- payload: the data useful to process the action
const initialState = { userName: undefined };
export default function userReducer(
state = initialState,
action
) {
switch (action.type) {
case "SET_USERNAME": {
// The state must stay immutable
return { ...state, userName: action.payload };
}
default:
return state;
}
}
Well, Romain, you told us that you will explain what is under the hood and finally you explain how to use it.
Sorry guys, I needed to put some context before going deep into Redux ;)
Under the hood
Note: The implementation I will show you is based on the version 4.1.2.
createStore
is a closure which has a state
object and returns the methods getState
and dispatch
:
function createStore(reducer) {
let state;
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
return action;
};
// Populates the state with the initial values of reducers
dispatch({ type: "@@redux/INIT" });
return { getState, dispatch };
}
Note: As you can see, the data is stored in a simple object and it's executed synchronously.
createStore can receive a preloadedState to initialize the state. It's not useful if you have initial states on your reducers.
Multiple reducers
For the moment, we saw a simple case with a single reducer. But in applications, you usually more than one. Otherwise redux
is maybe a little bit overkill for your use case.
Redux can structure the store in a clean way, by dividing our store.
Let's go use the function combineReducers
.
For example, with the previous reducer userReducer
, and the new one settingsReducer
:
const initialState = { maxSessionDuration: undefined };
export default function settingsReducer(
state = initialState,
action
) {
switch (action.type) {
case "SET_": {
return {
...state,
maxSessionDuration: action.payload,
};
}
default:
return state;
}
}
The combination of reducers will be:
import { combineReducers } from "redux";
import userReducer from "./userReducer";
import settingsReducer from "./settingsReducer";
export default combineReducers({
user: userReducer,
settings: settingsReducer,
});
We will get the state
:
{
user: {
userName: undefined,
},
settings: {
maxSessionDuration: undefined,
},
}
I will tell you amazing, the code of createStore
doesn't change. So how does combineReducers
work?
function combineReducers(reducersByNames) {
return (state, action) => {
let hasChanged = false;
const nextState = {};
Object.entries(reducersByNames).forEach(
([reducerName, reducer]) => {
// A reducer cannot access states of other ones
const previousReducerState = state[reducerName];
// Calculate the next state for this reducer
const nextReducerState = reducer(
previousReducerState,
action
);
nextState[reducerName] = nextReducerState;
// Notice the strict equality
hasChanged =
hasChanged ||
nextReducerState !== previousReducerState;
}
);
// If there is no changes, we return the previous state
// (we keep the reference of the state
// for performance's reasons)
return hasChanged ? nextState : state;
};
}
Note: We can see bellow that if there is
mutations of the state, then Redux will be completely lost and will not see changes with the strict equality.
Note: In the real code, there are a lot of checks to be sure everything is working correctly. I have simplified the code to explain how it works without superfluous code.
Tip: The action is passed to all reducers, so the reducer has to to check if it knows how to handle the action. So, we can have multiple reducers which are able to handle a same action.
Listeners
What is it?
A listener is a callback we can subscribe
to potential changes of the Redux state. This listener is directly executed after an event is dispatched.
Previously I talked about potential changes because, after an action has been dispatched, there is not necessarily changes. For example if none of the reducers know how to handle the event.
Once subscribed, we get a callback to be able to unsubscribe
it.
An example of use case
For example if you don't want, or can't use the plugin Redux DevTools
. It can be useful to be able to see the Redux state at any time. In this case, you can use a listener:
import { createStore } from "redux";
import userReducer from "./userReducer";
const store = createStore(userReducer);
store.subscribe(
() => (window.reduxState = store.getState())
);
And now you can see, at any time, the state by typing in your favorite browser's console: reduxState
.
Let's see some code
Our createStore
becomes:
function createStore(reducer) {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
return action;
};
const subscribe = (listener) => {
listeners = [...listeners, listener];
// Returns the `unsubscribe` method
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
dispatch({ type: "@@redux/INIT" });
// We now expose the `subscribe` method
return { getState, dispatch, subscribe };
}
Note: I have simplified a lot the method subscribe. In reality, there are a lot of check, especially:
- not to be able to subscribe/unsubscribe when an action is dispatched
- ensure this is a function passed as listener
- be able to call the unsubscribe mutliple times without errors
- ...
Observable
Some background
It can be an unknown feature for you, but the store is an Observable
, so if you use for example RxJS
, you can add an Observer
to be notified of state's changes.
import { from } from "rxjs";
import { createStore } from "redux";
import userReducer from "./userReducer";
const store = createStore(userReducer);
const myObserver = {
next: (newState) =>
console.log("The new redux state is: ", newState),
};
from(store).subscribe(myObserver);
// Let's change the username
store.dispatch({
type: "SET_USERNAME",
payload: "Bob l'Γ©ponge",
});
How does it work?
To be an Observable
, the store just has to add the Symbol.observable
(or @@observable
if Symbol.observable
is undefined) to its key and implements an observable
method.
Its implementation is really simple because it reuses the implementation of listeners
:
function createStore(reducer) {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
return action;
};
const subscribe = (listener) => {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const observable = () => ({
subscribe: (observer) => {
// The method `observeState` only notifies the Observer
// of the current value of the state
function observeState() {
observer.next(getState());
}
// As soon as the Observer subscribes we send the
// current value of the state
observeState();
// We register the `observeState` function as a listener
// to be notified of next changes of the state
const unsubscribe = listenerSubscribe(observeState);
return {
unsubscribe,
};
},
});
dispatch({ type: "@@redux/INIT" });
return {
getState,
dispatch,
subscribe,
[Symbol.observable]: observable,
};
}
replaceReducer
Implementation
When you use code splitting, it can happened you do not have all reducers while creating the store. To be able to register new reducers after store
creation, redux give us access to a method named replaceReducer
which enables the replacement of reducers by new ones:
function createStore(reducer) {
let state;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
return action;
};
const subscribe = (listener) => {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const observable = () => {
const listenerSubscribe = subscribe;
return {
subscribe: (observer) => {
function observeState() {
observer.next(getState());
}
observeState();
const unsubscribe = listenerSubscribe(observeState);
return {
unsubscribe,
};
},
};
};
const replaceReducer = (newReducer) => {
reducer = newReducer;
// Like the action `@@redux/INIT`,
// this one populates the state with
// initial values of new reducers
dispatch({ type: "@@redux/REPLACE" });
};
dispatch({ type: "@@redux/INIT" });
return {
getState,
dispatch,
subscribe,
[Symbol.observable]: observable,
replaceReducer,
};
}
Warning: I didn't tell you previously, but like
@@redux/INIT
,@@redux/REPLACE
should be handled in your reducers. Otherwise you will have problems
with hot reload and your reducers will become unpredictable.Note: Actually these actions do not have these types, they are suffixed.
Example of usage
Let's use this new method replaceReducer
to register a new reducer. At the store creation we only register the reducer userReducer
, then we register the reducer counterReducer
:
export default function counterReducer(
state = { value: 0 },
action
) {
switch (action.type) {
case "INCREMENT": {
return { ...state, value: state.value + 1 };
}
default:
return state;
}
}
The replacement of reducers will be:
import { createStore, combineReducers } from "redux";
import userReducer from "userReducer";
import counterReducer from "counterReducer";
const store = createStore(
combineReducers({ user: userReducer })
);
// Will print { user: { userName: undefined } }
console.log(store.getState());
store.replaceReducer(
combineReducers({
user: userReducer,
counter: counterReducer,
})
);
// Will print
// { user: { userName: undefined }, counter: { value: 0 } }
console.log(store.getState());
Note: If in this example, I have subscribed a listener after the store creation, this one would have been triggered after the reducers modification.
Middleware
Presentation
A middleware is a tool that we can put between two applications. In the Redux case, the middleware will be placed between the dispatch call and the
reducer. I talk about a middleware (singular form), but in reality you can put as much middleware as you want.
An example of middleware could be to log dispatched actions and then the new state.
How do we write a middleware?
I'm gonna directly give you the form of a middleware without explanation because I will never do better than the official documentation.
const myMiddleware = (store) => (next) => (action) => {
// With the store you can get the state with `getState`
// or the original `dispatch`
// `next`represents the next dispatch
return next(action);
};
Example: middleware of the loggerMiddleware
const loggerMiddleware = (store) => (next) => (action) => {
console.log(`I'm gonna dispatch the action: ${action}`);
const value = next(action);
console.log(`New state: ${value}`);
return value;
};
redux-thunk middleware example
Until now, we dispatched actions synchronously. But in an application it can happened we would like to dispatch actions asynchronously. For example, after having resolved an AJAX call with axios (fetch or another library).
The implementation is really simple, if the action dispatched is a function, it will execute it with getState
and dispatch
as parameters. And if it's not a function, it passes the action to the next middleware
or reducer
(if there is no more middleware).
const reduxThunkMiddleware =
({ getState, dispatch }) =>
(next) =>
(action) => {
if (typeof action === "function") {
return action(dispatch, getState);
}
return next(action);
};
The thunk action creator will be:
function thunkActionCreator() {
return ({ dispatch }) => {
return axios.get("/my-rest-api").then(({ data }) => {
dispatch({
type: "SET_REST_DATA",
payload: data,
});
});
};
}
Store configuration
Before talking about how to configure middlewares with redux, let's talk about Enhancer. An enhancer (in redux) is in charge of 'overriding' the original behavior of redux. For example if we want to modify how works the dispatch (with middlewares for instance), enrich the state with
extra data, add some methods in the store...
The enhancer is in charge of the creation of the store with the help of the createStore
function, then to override the store created. Its signature is:
// We find the signature of the `createStore` method:
// function(reducer, preloadedState) {}
const customEnhancer =
(createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
return store;
};
As you may notice, to use middlewares we need an enhancer
which is provided by redux (the only one enhancer provided by redux) which is named applyMiddleware
:
// Transform first(second(third))(myInitialValue)
// with compose(first, second, third)(myInitialValue)
function compose(...functions) {
return functions.reduce(
(f1, f2) =>
(...args) =>
f1(f2(...args))
);
}
const applyMiddleware =
(...middlewares) =>
(createStore) =>
(reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
const restrictedStore = {
state: store.getState(),
dispatch: () =>
console.error(
"Should not call dispatch while constructing middleware"
),
};
const chain = middlewares.map((middleware) =>
middleware(restrictedStore)
);
// We rebuild the dispatch with our middlewares
// and the original dispatch
const dispatch = compose(chain)(store.dispatch);
return {
...store,
dispatch,
};
};
Note: Perhaps you used to use the method
reduce
with an accumulator initialized with a second parameter:
const myArray = [];
myArray.reduce((acc, currentValue) => {
// Do some process
}, initialValue);
If you do not give an initial value (no second parameter), the first value of your array will be taken as the initial value.
Note: Looking at the applyMiddleware implementation, you can notice that the middleware's signature could be
(store, next) => action
.
You can see these PRs which are about this: PR 784, PR 1744
The createStore
becomes:
function createStore(reducer, preloadedState, enhancer) {
// We can pass the enhancer as 2nd parameter
// instead of preloadedState
if (
typeof preloadedState === "function" &&
enhancer === undefined
) {
enhancer = preloadedState;
preloadedState = undefined;
}
// If we have an enhancer, let's use it to create the store
if (typeof enhancer === "function") {
return enhancer(createStore)(reducer, preloadedState);
}
let state = preloadedState;
let listeners = [];
const getState = () => state;
const dispatch = (action) => {
state = reducer(state, action);
listeners.forEach((listener) => listener());
return action;
};
const subscribe = (listener) => {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter((l) => l !== listener);
};
};
const observable = () => {
const listenerSubscribe = subscribe;
return {
subscribe: (observer) => {
function observeState() {
observer.next(getState());
}
observeState();
const unsubscribe = listenerSubscribe(observeState);
return {
unsubscribe,
};
},
};
};
const replaceReducer = (newReducer) => {
reducer = newReducer;
dispatch({ type: "@@redux/REPLACE" });
};
dispatch({ type: "@@redux/INIT" });
return {
getState,
dispatch,
subscribe,
[Symbol.observable]: observable,
replaceReducer,
};
}
An now we can use our middlewares:
import loggerMiddleware from "./loggerMiddleware";
import { createStore, applyMiddleware } from "redux";
import userReducer from "./userReducer";
// In this case the enhancer is passed as 2nd parameter
const store = createStore(
userReducer,
applyMiddleware(loggerMiddleware)
);
Conclusion
As you can see the code of Redux is pretty simple but so much powerful. Data is only stored in an object, and changes are done through reducers.
You can also subscribe to changes, and that's what is done in binding libraries like react-redux.
Keep in mind that Redux has been developped to be synchronous, and if you to handle asynchronous action creator you will have to use a middleware, like redux-thunk or redux-saga.
Due to performance, like for React state, you can't mutate the state, but recreate a new one. If it's too much boilerplate for you, you can give a chance to redux-toolkit which is using immer under the hood, to write simpler code and "mutate" the state.
Watch out, do not use Redux by default, but only if you need it.
If you work with React, you have some other possibilities like:
- React state
-
React context, probably combined with
useState
oruseReducer
(you can see my article on the performance problem you can encounter here) -
atom state management library like
jotai
,recoil
. -
async state manager libraries:
react-query
,swr
, ...
Do not hesitate to comment and if you want to see more, you can follow me on Twitter or go to my Website.
Top comments (0)