Photo by Hosea Georgeson on Unsplash
But wait we have React hooks now, we don't need Redux anymore, right ?
If you are not a React dev, React hooks are the latest addition to React and they are absolutely awesome ⚡, but they are not replacing Redux. If you are still unconvinced, I would strongly recommend Eric Elliot article, Do React Hooks Replace Redux?.
For now if you want to continue without reading Elliot article, here is the tl;dr:
- Redux is not just a library, it's architecture that proved very effective in building scalble and maintainable code.
- While you can recreate the functionalities of Redux using the createContext and React Hooks, there are no clear gains from that and you would lose access to the powerful debugging capabilities in Redux devtools.
I hope that you are convinced and that you would join us in this tour. Now before we jump right in, please have a look at our brochure of functional programming concepts that you will see quite often inside Redux. If however, you feel confident in these concepts you can skip to the start of the tour.
TOC
Brochure of functional programming concepts
We are not going to try and give an exhaustive explanation of those concepts here as I believe it would be futile to try and jam all of those in a single article. However, I'll try to explain just enough so you can gain the most from this article.
Pure Functions
- Functions which their return value is determained by the arguments passed to them.
- They don't access or modify values outside of their scope.
Closures
Closures are created at the creation of new functions and they allow those functions to access the outer scope.
function outer() {
const savedInClosure = true;
return function() {
if (savedInClosure) {
console.log('I always have closure');
}
};
}
const doYouHaveClosure = outer();
doYouHaveClosure(); // 'I always have closure'
High order functions
Functions that receive functions as an argument and/or return another function. Also, Yup the code above is a high order function, well done for noticing 😉.
Currying
Currying is the technique of taking a function that takes multiple arguments and transforming it into a series of functions that takes one argument at a time. Now, you might scream to yourself why would I ever want to do that. Well the simple answer is "specialized Functions and separation of complexity". Let's have a look at the canonical example of currying:
// Before currying
const add_notCurrying = (x, y) => x + y;
// after currying
const add_currying = x => y => x + y;
// specialize functions
const add2 = add_currying(2);
add2(8); // 10
Now say your manager comes to you and tell you, "the add functions must do a bunch of checks and API calls before committing the first argument and must do totally different checks and API calls to commit the second argument". In the uncurried version you would have to jam all that complexity into one functions, while on the curried version of add
you can separate it.
Function Composition
Function composition is the process combining functions to build more sophisticated ones, and yes in the examples above we've already done some function composition. However, the techniques that I want to explain here is the one that might give you headache first time you see it:
const myFuncs = [func1, func2, func3, func4];
const compose = arr => arr.reduce((a, b) => (...args) => a(b(...args)));
const chain = compose(myFuncs);
WAAAAAAAAIT...., Now trust me if you don't have experience in functional programming, like I was when I first saw this, having a reaction like "🤬🤬🤬🤬" is in my opinion the healthiest response you can have. Unless you are well versed in functional programming, this will not be intuitive and it might takes time for it to click in your mind, but. For now, know that all compose does is help us get to something like this function.
const composed = (...args) => func1(func2(func3(func4(...args))));
As you can see the final function we get from compose, calls the functions in the array right-to-left and passing the return of each function as the argument to the previous one. Now with that mental framework in mind try to take a look at a refactored version from the code above.
const myFuncs = [
() => {
console.log(1);
},
() => {
console.log(2);
},
() => {
console.log(3);
},
() => {
console.log(4);
}
];
let chain = myFuncs[0];
for (let index = 1; index < myFuncs.length; index++) {
const currentRingInTheChain = myFuncs[index];
// This is necessary to avoid recursion. Basically we storing different instances of functionsChainSoFar in closure scopes
const functionsChainSoFar = chain;
chain = (...args) => functionsChainSoFar(currentRingInTheChain(...args));
}
chain(); // 4 , 3, 2, 1
I hope that clarified what compose
does but if you are still not 100% sure, don't worry too much. Again this might take time and it does require a mental shift.
BONSUS ROUND: what do you think the following code will log?.
const myFuncs = [
func => () => {
console.log(1);
func();
},
func => () => {
console.log(2);
func();
},
func => () => {
console.log(3);
func();
},
func => () => {
console.log(4);
func();
}
];
const hakuna = () => console.log('Mattata');
const secret = compose(myFuncs)(hakuna);
secret(); // what do you think this will log?
Give it a go but if you get stuck don't worry will revisit this again in the article.
Start of The Tour
The best way to start the tour is to see how we are creating a Redux store and what are the pieces that plays a part in that. So let's have a look at this sample from the docs.
import { applyMiddleware, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import monitorReducersEnhancer from './enhancers/monitorReducers';
import loggerMiddleware from './middleware/logger';
import rootReducer from './reducers';
export default function configureStore(preloadedState) {
const middlewares = [loggerMiddleware, thunkMiddleware];
const middlewareEnhancer = applyMiddleware(...middlewares);
const enhancers = [middlewareEnhancer, monitorReducersEnhancer];
const composedEnhancers = composeWithDevTools(...enhancers);
const store = createStore(rootReducer, preloadedState, composedEnhancers);
return store;
}
There is a lot going on here, we are using redux-thunk, attaching the redux-devtools-extensions and a lot more. So, let's divide and conquer and separate the code above into four domains.
- The
reducers
- The
createStore
functions - The
enhancers
- The
middlewares
First: rootReducer
, the maker of the new state
The rootReducer
function is the first of the three arguments that createStore
takes and chances are you already know that redux reducers
are functions that takes the current state and an action and return a new state. You might also already know that the reducers
must be pure functions.
However, have you ever wondered "why reducers have to be pure functions?" 🤔. Well there is a very good reason, but unfortunately, there isn't a piece of code that I can point to and tell you "if the is NOT pure function it will ALWAYS break". Yet the fact that reducers
must be pure functions is at the heart of what Redux aim to be, and that is "a state store with predictable state mutation". Redux, achieves that by adehering to three self-imposed principles:
- A Single source of truth
- State is read-only
- Changes to the state are made with pure functions
If that didn't click immediately in your mind don't worry we will see those principles again in this article.
So, reducers are pure functions. They take the current state and an action as arguments and return a new state object, got it 👍. But how about combineReducers
, how does that magical function work. Well combineReducers
is an awesome utility function that help us keep our code modular, but really there is nothing magical about it. combineReducers
is a high order function and all what it does is:
- Extract an array from the reducer object passed into it (note that the reducer keys match the shape of the state tree).
- Return a new
reducer
function.- This function will make the next state by looping over the array of reducers keys and call the coresponding
reducer
. - Finally, it will return the next state.
- This function will make the next state by looping over the array of reducers keys and call the coresponding
Take a look at the trim down version of combineReducers
:
const reducers = {
someState: reducerOfSomeState,
anotherState: reducerOfAnotherState
};
function combineReducers(reducers) {
const reducerKeys = Object.keys(reducers);
return function combinedReducer(state = {}, action) {
const nextState = {};
for (let i = 0; i < reducerKeys.length; i++) {
const key = reducerKeys[i];
const reducer = reducers[key];
const previousStateForKey = state[key];
const nextStateForKey = reducer(previousStateForKey, action);
nextState[key] = nextStateForKey;
}
return nextState;
};
}
const rootReducer = combineReducers(reducers);
Finally, there is an important insight that you might already noticed by looking at combineReducers
, which is, each time the rootReducers
gets called all of the reducers
in your app will be called to create the next state.
Second: createStore
, the store maker
In it's simplest form createStore
return a state object and few methods. However it also accepts extra arguments that enhances 😉 the store but more on that later. For now let's make sure we understand a simpler version of createStore
.
We've already seen the three principles that redux is built on. Now, let's take another look at them and try building our own redux replica 🛠:
- A Single source of truth ≈ we should have a single store object.
- State is read-only ≈ state object should not be mutated directly, instead changes should be described and emitted using a method. (If don't understand how we got that from "state is read-only" then that's fair after all it's only four words. However, the docs elaborate on the point and make the intention of the principle clear.)
- Changes are made with pure functions ≈ reducers have to be pure functions.
Adhering to the principles above our Redux replica might look something like this:
// An action to initialize our state
const ActionTypes = {
INIT: `@@redux/INIT${Math.random()
.toString(36)
.substring(7)}`
};
function createStore(rootReducer, initialState) {
let currentState = initialState;
const dispatch = action => {
currentState = rootReducer(action);
};
const getState = () => currentState;
// setting the initial state tree.
dispatch({ type: ActionTypes.INIT });
return {
dispatch,
getState
};
}
const myAwesomeStore = createStore(rootReducer, {});
Those few lines might not look like much, but they are equivalent to the core functionlites of Redux. Of course, Redux adds some checks to help developers avoid stupid mistakes like calling dispatch from inside a reducer or not calling dispatch
with a plain object. Also our replica doesn't support middleware
or enhancers
, yet at least.
Third: middleWares
, the ones in the middle
I knowwwwwwww 🤯,
Ok ok but seriously though, it's helpfully to think of them conceptually as a middleman between the dispatcher
and the the rootReducer
. SPOILER ALERT: At the the Enhancer section we will see that it's a little bit more complected than that.
Because actions go through middleware, there they can be changed, canceled or really anything else. There is a lot nuance in how to use middleware effectively, but in this article we will only focus on how they work inside Redux. So let's see that by examining what is probably the simplest middleware you will ever see.
const middledWare = ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
If you are eyes skipped the first line and immediately went to the body of the final function you might have seen that the logic is straightforward. However, once your your eyes aim back at the first line, bells in your head should start ringing CURRYING. Also, If you feel at all confused by this, don't disheartened because you are not alone, In fact this questions is one of FAQs in the docs Why does the middleware signature use currying?. In the next section we will see how this function signutre is being used by Redux inside applyMiddleware
, for now just remember the following from the middleware signature above.
- the first function will get called with the an object that has two properties
dispatch
andgetState
(the middleWareApi). - The second function gets called with
next
(the next middleWare). - The final function act as a
dispatch
and it gets called with an action.
FUN FACT 🤓: You might not have noticed it, but the code above is actully the source for redux-thunk.
Fourth: enhancers
, Augmenting createStore
As you might have already guessed, enhancers
are high order functions that take createStore
and return a new enhanced version of createStore
. Take a look a this sample implementation.
const ourAwesomeEnhancer = createStore => (reducer, initialState, enhancer) => {
const store = createStore(monitoredReducer, initialState, enhancer);
// add enhancer logic
return {
...store
// you can override the some store properties or add new ones
};
};
While it's rare that you might need to craft your own enhancers
, you are likely already using at least one, applyMiddleware
. Oh Yes, this is might be shocking to some but the notion of middlewares
is not in Redux createStore
. We add middlewares capabilities to our store by using the only enhancer
that ships with Redux applyMiddleware
.
To be specific the actually enhancer is the returned function from applyMiddleware
but they are referenced interchangeably in the docs.
The enhancer
function is first called from inside createStore
and there isn't anything magical or overly complected. As you will soon see soon. However before we see the code, we need to address an urgent problem 🚧. Because enhancers
take createStore
and returned enhanced version of createStore
, you can see how using those terms to explain the mechanics of the enhancer
can get convoluted very quickly. As such for the purposes of this section I'm introducing what I dubbed as placeholders terms:
- The originalStoreMaker: the
createStore
function that you can import from Redux. - The storeMaker: any function that has the same signature as the original storeMaker (accepts the same arguments and return the same API).
Alright then now let's see some code. Take a look at our Redux replica from above, now modified to accept enhancer
.
function createStore(rootReducer, initialState, enhancer) {
let currentState = initialState;
// Now accepts enhancers
if (typeof enhancer !== 'undefined' && typeof enhancer === 'function') {
return enhancer(createStore)(reducer, preloadedState);
}
const dispatch = action => {
currentState = rootReducer(action);
};
const getState = () => currentState;
// setting the initial state tree.
dispatch({ type: ActionTypes.INIT });
return {
dispatch,
getState
};
}
As I said nothing magical. It's just function that takes a storeMaker and return an enhanced storeMaker. Of course that's not to say that enhancer
can't be complex. It's to say that the complexity of an enhancer
is encapsulated inside it and determined by what it try to achieve AND not by how it interact with a storeMaker. This subtle distinction is important as we in the rest of this section examine the implementation of the most widely used enhancer
in Redux, applyMiddleware
.
The applyMiddleWare
function applyMiddleware(...middlewares) {
return createStore => (...args) => {
const store = createStore(...args);
let dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
);
};
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
};
};
}
OK that was the whole thing now let's unpack it. Let's first quickly understand the curring part at the top. What we really need to know here is what arguments will those functions gets called with, luckily for us we already know that:
applyMiddleware
takesmiddlewares
return anenhancer
.enhancers
take a storeMaker and return an enhanced storeMaker.
From that we can bring our focus back to the body of the final function and noting what it has in closure.
// In closure: [middlewares], createStore
// This final function is a storeMaker
(...args) => {
const store = createStore(...args);
let dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
);
};
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
};
};
Much better, now Somewhere in the code this storeMaker will get called with rootReducer
and initialState
. Jumping inside the function, the first two lines create the store and assign a function to a variable named dispatch
. As the error message say this is done to prevent developer from accidentally calling dispach
inside a storeMaker.
// In closure: middlewares and the original createStore.
// + more code above
const store = createStore(...args);
let dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
);
};
// + more code below
Before looking at the second piece of code try to remember the signature of a middleware
in Redux that we've seen before. Here the first of those curried function of each middleware
gets called. After this part of code we will get an array of functions where each has a reference in their closure to the middleWareAPI
object.
// In closure: middlewares and the original createStore.
// + more code below
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
// + more code below
Brace yourself, the next line is probably the most intimidating part of the code. Largely because of the compose
function. Nonetheless, give it a go 💪 and take this hint: all the functions in the chain
variable return a function.
// In closure: middlewares and the original createStore.
// + more code below
dispatch = compose(...chain)(store.dispatch);
// + more code below
If you went through our Brochure of functional programming concepts, seeing the code above might rang few bells inside your head. Because this code look very similar to the code from the BONUS ROUND in the function composition sub-section. Speaking of which, what did you guess the code from there will log?....
well let's have another look.
const myFuncs = [
func => () => {
console.log(1);
func();
},
func => () => {
console.log(2);
func();
},
func => () => {
console.log(3);
func();
},
func => () => {
console.log(4);
func();
}
];
const hakuna = () => console.log('Mattata');
const secret = compose(myFuncs)(hakuna);
secret(); // 1, 2, 3, 4, Matata
Yes, if you tried to run the code in the console, you've seen that it logs 1, 2, 3, 4, Matata. The code seems to have ran left-to-right. Except after the returned function from compose
gets called with hakuan
, we don't have an array anymore!. Where is the left-to-right coming from?? It's because of Closures and callbacks. Ok I'm guessing that wasn't super helpful 😅. No worries though, I'll try to explain a little better but first to avoid confusion, I'm going to need once again to introduce new placeholders terms.
- level1Func: any function inside the
myFuncs
array. - level2Func: any function that is returned by a level1Func.
Alright, let's recap what is it that we want to achieve. We want somehow for all of level2Func to run in order from left-to-right. We can see in the array that each level1Func takes a callback as an argument and then that callback gets called inside it's level2Func. So it's seems that we can achieve our goal if somehow each level1Func got called with the next level2Func.
OK Ok gears are turning ⚙⚙ we are closing into something. We know by now that compose will return a function that will call functions right-to-left and passing each return to the previous function in the array. But god it's too hard running that code inside my mind 😵. Maybe if we saw how that would look like differently.
const composed = (...args) => func1(func2(func3(func4(...args))));
AHA!, As composed
gets called and the functions gets called right-to-left, each level1func will be called by the next level2func. Well done You got it 👏. That is exactly how we end-up with a function that resemble a chain in how it runs left-to-right. The last thing to point out and hammer home is that hakuna
function is the first argument that gets passed by composed and as such it's the last function in the chain
Now with this new found understanding let's look back at the line code from applyMiddleware
. I hope you can see by now how the chain is made, that each middleWare will calls the next one and that the last function in the chain is store.dispatch
which sets the new state (but NOT create it) to the store.
// In closure: middlewares and the original createStore.
// + more code below
dispatch = compose(...chain)(store.dispatch);
// + more code below
Finally, because this is after all a storeMaker function we return the store and of course override the dispach
property.
return {
...store,
dispatch
};
The Gift shop
The above is everything about how the core of Redux works. There are few more methods that ships with Redux and while they will not alter your understanding of how Redux works, they are worth mentioning. Here is a quick list.
- replaceReducer: Give you the ability replace the rootReducer of the store. Interestingly in some setups you can use it to add new reducers rather than just replacing the whole
rootReducer
. - subscribe: Give you the ablity to pass a callback that will get called after any action gets dispatched.
- observable: Can be used in libraries like RxJS. Also allowes you to subscribe to changes.
Congratulation you made it 🎊🎊👏👏. Now you understand how Redux works under the hood and hopefully have gained an appreciation for the power functional programming.