This content originally appeared on DEV Community and was authored by Sanjeev Sharma
Hey! ?
If you're a Front-end developer or aspiring to become one, I am sure you might have come across Redux by now.
Maybe you probably know what Redux is, maybe you don't. Maybe you have been using it for quite a while but you don't fully understand it. You start a new project and then just copy a bunch of stuff from somewhere and just get it set up. TBH, I've done this before. I had slight idea of everything and what should be in place for Redux to work. That has worked for me until now, but sometimes I'd run into issues that would require a little more knowledge.
?♂️ Therefore, I decided to study the Redux API. I watched a bunch of videos online and read the docs. Along with that I wrote this article.
? To my surprise, I found that 80-90% of the things we do in Redux is just plain JS. It's just objects and functions. If it feels complicated, you might wanna go back to JS basics. But if you're confident on the JS part, Redux won't be tough.
⚠️ Before starting I would like to mention that this article covers Redux only. It does not talk about React or any other framework or their interactions with Redux.
?? To make the most out of this article, you can code along. I have added snippets for everything we're going to discuss.
? What is Redux?
Well, if you clicked on this article, I am pretty sure you already know the answer. But just for the sake of answering the question, let's do it.
Redux is a state management library. It stores the state of your app and provides methods to interact with that state. It can be used with any framework like React, Angular, Vue etc.
Installation
npm install redux
For this article we'd only need to install redux
, nothing else.
Redux API surface comes with only 5 methods.
We'll study each one of these in detail.
? compose
This method doesn't even have anything to do with Redux. The purpose of this method is to bundle multiple functions into one.
Let's say we have 3 mathematical functions: half
, square
and double
.
If we want to apply all three operations in order we'd need to do something like this:
const double = (num) => num * 2;
const square = (num) => num * num;
const half = (num) => num / 2;
const halfSquareDouble = (num) => half(square(double(num)));
console.log(halfSquareDouble(2)); // 8
But we can achieve the same thing in a much cleaner way using compose
:
import { compose } from "redux";
const double = (num) => num * 2;
const square = (num) => num * num;
const half = (num) => num / 2;
const halfSquareDouble = compose(half, square, double);
console.log(halfSquareDouble(2)); // 8
compose
will combine all our functions into a single function.
? Note: compose
will start picking up functions from the right end. That means if the order was compose(half, double, square)
then the result for the same call would have been 4.
? createStore
This methods creates the Redux store. It takes one mandatory argument reducer
, and two optional arguments - preloadedState
(also know as initialState
) and enhancer
.
So, what is a Reducer? In simple terms, Reducer is just a pure function which takes two arguments - state
and action
and returns one value which is the new state
.
Understand it like this, there is a perfect world/simulation which is in some state
X. Something happens; some action
is taken. We don't care where the action took place or who was responsible for it. All we know that something happened and that might change the state of our world. It is the reducers' job to figure out the new state
Y.
const reducer = (state, action) => {
return state
}
This is the simplest reducer you can create.
When we call createStore
method, it returns an object.
import { createStore } from 'redux'
const reducer = (state, action) => {
return state
}
const initialState = { value: 0 }
const store = createStore(reducer, initialState)
That object has 4 methods:
1️⃣ getState
: This method is used to get the state of your app.
console.log(store.getState()) // { value: 0 }
2️⃣ subscribe
: This method is used for subscribing to the changes on our store. Pass a function to this method and it will get called anytime state changes.
store.subscribe(() => console.log("State changed!"))
3️⃣ dispatch
: This method is used for dispatching actions. Actions go inside reducers with the current state of your app and might update the state.
?️♂️ We've introduced one more term here - action
, so let's talk about it.
If you remember reducer takes action to update the state. It's the action that tells the reducer that something just happened. It can be user clicking on a button, user logging in, user adding a product, etc. Anything that is meant to change the state of our app is an action.
Of course we've full control over them. We're the ones defining them. How do create them? Well, there's a specific style you should follow.
const incrementAction = {
type: 'INCREMENT'
}
Actions are basically objects, that have a type
key. That's it. It can have additional keys too, but type
is mandatory.
Let's refactor our reducer now to make use of this action.
const reducer = (state = initialState, action) => {
if (action.type === 'INCREMENT') {
return { value: state.value + 1 }
}
return state
}
On line 1, we've added intialState
as a default argument. By doing this we can remove it from the createStore()
call. This is actually a best practice.
On line 2, we're checking if the action that we received is of type INCREMENT
.
On line 3, we're preparing our new state. This is important. Never modify your state directly. Always return a newly created object. If you don't do so, the reference to the state object won't change, and your app wouldn't get notified of the changes.
state.value++ // ?♂️ DON'T DO THIS
return { value: state.value + 1 } // ? WORKS FINE
On line 4, we finally return our old state, in case we didn't find a matching action. This is important too. Your reducer should always return a state.
Now, that our reducer is updated, let's dispatch an action.
import { createStore } from "redux";
const initialState = { value: 0 };
const incrementAction = {
type: "INCREMENT"
};
const reducer = (state = initialState, action) => {
if (action.type === "INCREMENT") {
return { value: state.value + 1 };
}
return state;
};
const store = createStore(reducer);
console.log(store.getState()); // { value: 0 }
store.dispatch(incrementAction);
console.log(store.getState()); // { value: 1 }
What if we want to increment by 5? I cannot do that right now. But if we see carefully, all we have written up until now is basic JavaScript. Stuff that you probably know. We can extend our code a bit and achieve our goal.
Remember action can have additional keys? We'll create one more action.
import { createStore } from "redux";
const initialState = { value: 0 };
const incrementAction = {
type: "INCREMENT"
};
const addAction = {
type: "ADD",
payload: 5,
}
const reducer = (state = initialState, action) => {
if (action.type === "INCREMENT") {
return { value: state.value + 1 };
}
if (action.type === "ADD") {
return { value: state.value + action.payload }
}
return state;
};
const store = createStore(reducer);
store.dispatch(addAction)
console.log(store.getState()) // { value: 5 }
Okay! So far so good. But 5 is not enough, let's create one for 10 too, and then one for 100 too? Feels stupid! We cannot cover every number out there.
Okay! What if we do something like this?
store.dispatch({ type: "ADD", payload: 5 })
store.dispatch({ type: "ADD", payload: 10 })
store.dispatch({ type: "ADD", payload: 100 })
Yes! this gets the job done but this is not scalable. If later we decide to call it INCREASE_BY
instead of ADD
, then we'll have to update it everywhere. Also, there's a chance that we might make a type and end up writing INCRAESE_BY
. Good luck finding that typo! ?
There's an elegant way to solve this using Action Creators.
? Action Creators are just functions that create actions for you.
const add = (number) => {
return {
type: "ADD",
payload: number
}
}
store.dispatch(add(5))
store.dispatch(add(10))
store.dispatch(add(100))
We created a function add
that returns action object. We can call it anywhere and it will create an action object for us.
This solution is much cleaner and it is widely used.
Our updated code now looks like this:
import { createStore } from "redux";
const initialState = { value: 0 };
// constants
const INCREMENT = "INCREMENT";
const ADD = "ADD";
// action creators
const increment = () => ({ type: INCREMENT });
const add = (number) => ({ type: ADD, payload: number });
const reducer = (state = initialState, action) => {
if (action.type === INCREMENT) {
return { value: state.value + 1 };
}
if (action.type === ADD) {
return { value: state.value + action.payload };
}
return state;
};
const store = createStore(reducer);
console.log(store.getState()); // { value: 0 }
store.dispatch(increment());
store.dispatch(add(2));
console.log(store.getState()); // { value: 3 }
Notice that, we've stored "INCREMENT"
and "ADD"
as constants. That's because we were repeating them in our reducers, and there was a chance for typo. It's a good practice to store action types as constants in one place.
? If you've made it this far, congratulations. With all the knowledge you have right now, you can start creating apps with Redux. Of course there's more left, but you've covered a significant part of the API. Well done!
4️⃣ replaceReducer
: This method is used for replacing the current root reducer function with a new one. Calling this method will change the internal reducer function reference. This comes into play, when you're splitting your code for performance.
const newRootReducer = combineReducers({
existingSlice: existingSliceReducer,
newSlice: newSliceReducer
});
store.replaceReducer(newRootReducer);
? bindActionCreators
Now that we have some idea about action creators and dispatch, we can talk about this method.
dispatch(increment())
dispatch(add(5))
This is how we've dispatched actions until now. But there's a simpler way to do this.
const actions = bindActionCreators({ add, increment }, store.dispatch)
actions.increment()
actions.add(4)
bindActionCreators
takes two arguments:
- An object with all the action creators inside it.
- The method we want to bind our action creators to.
It returns an object, which looks identical to the first argument we passed in. The only difference is, now we can call those methods directly, without calling dispatch explicitly.
What's the benefit of doing this?
The only use case for bindActionCreators is when you want to pass some action creators down to a component that isn't aware of Redux, and you don't want to pass dispatch or the Redux store to it. - Redux Docs
Also, note that what we did is just plain JS, we could've achieved the same result by writing our own function that binds action creators to dispatch; without calling bindActionCreators
.
? combineReducers
When you're developing a huge app where you can segregate data, it makes sense to have multiple reducers to reduce complexity. This method will combine all those multiple small reducers and return one reducer, generally called as root reducer, that our createStore
method can use.
First, let's see why do we wanna have multiple reducers. Consider the following code.
import { createStore } from "redux";
// constants
const CHANGE_USER_EMAIL = "CHANGE_USER_EMAIL";
const ADD_PRODUCT = "ADD_PRODUCT";
// action creators
const changeUserEmail = (email) => ({
type: CHANGE_USER_EMAIL,
payload: { email }
});
const addProduct = (product) => ({
type: ADD_PRODUCT,
payload: { product }
});
const initialState = {
user: {
name: "Mark",
email: "mark@facebook.com"
},
cart: {
products: []
}
};
const reducer = (state = initialState, action) => {
if (action.type === CHANGE_USER_EMAIL) {
return {
...state,
user: {
...state.user,
email: action.payload.email
}
};
}
if (action.type === ADD_PRODUCT) {
return {
...state,
cart: {
...state.cart,
products: [...state.cart.products, action.payload.product]
}
};
}
return state;
};
const store = createStore(reducer);
console.log(store.getState());
// { user: { name: 'Mark', email: 'mark@facebook.com' }, cart: { products: [] } }
store.dispatch(changeUserEmail("mark@instagram.com"));
console.log(store.getState());
// { user: { name: 'Mark', email: 'mark@instagram.com' }, cart: { products: [] } }
As we can see this reducer is already looking a bit complex. As our app grows, data will be nested to deeper levels and size of the reducer will grow as well.
If we think about it, user
and cart
are two entirely different data points. We can split them into two different reducers. Let's do it.
const initialState = {
user: {
name: "Mark",
email: "mark@facebook.com"
},
cart: {
products: []
}
};
const userReducer = (user = initialState.user, action) => {
if (action.type === CHANGE_USER_EMAIL) {
return {
...user,
email: action.payload.email
};
}
return user;
}
const cartReducer = (cart = initialState.cart, action) => {
if (action.type === ADD_PRODUCT) {
return {
...cart,
products: [...cart.products, action.payload.product]
};
}
return cart;
}
Now we have two simple reducers and even the code looks clean. But createStore
only takes a single reducer, which one should we pass?
Both. Using combineReducers
.
const rootReducer = combineReducers({
user: userReducer,
cart: cartReducer
});
const store = createStore(rootReducer);
This method takes an object, where keys can be anything but values should be our reducers. It will return a single reducer that can be passed to createStore
.
Our complete code looks like this now.
import { combineReducers, createStore } from "redux";
// constants
const CHANGE_USER_EMAIL = "CHANGE_USER_EMAIL";
const ADD_PRODUCT = "ADD_PRODUCT";
// action creators
const changeUserEmail = (email) => ({
type: CHANGE_USER_EMAIL,
payload: { email }
});
const addProduct = (product) => ({
type: ADD_PRODUCT,
payload: { product }
});
const initialState = {
user: {
name: "Mark",
email: "mark@facebook.com"
},
cart: {
products: []
}
};
const userReducer = (user = initialState.user, action) => {
if (action.type === CHANGE_USER_EMAIL) {
return {
...user,
email: action.payload.email
};
}
return user;
};
const cartReducer = (cart = initialState.cart, action) => {
if (action.type === ADD_PRODUCT) {
return {
...cart,
products: [...cart.products, action.payload.product]
};
}
return cart;
};
const rootReducer = combineReducers({
user: userReducer,
cart: cartReducer
});
const store = createStore(rootReducer);
console.log(store.getState());
// { user: { name: 'Mark', email: 'mark@facebook.com' }, cart: { products: [] } }
store.dispatch(changeUserEmail("mark@instagram.com"));
console.log(store.getState());
// { user: { name: 'Mark', email: 'mark@instagram.com' }, cart: { products: [] } }
? Store Enhancers
If you remember, createStore
takes an optional argument - enhancers
.
Enhancers are nothing but higher order functions. They add some extra functionality to our store. For example, Redux dev tools is an enhancer.
We won't talk much about enhancers here, because we will rarely create any new enhancers. Let's discuss this in detail in a separate article.
⛓ Middlewares
Middlewares provide us with the ability to intercept actions and do something we want to before that action reaches the reducers. We can log actions, log store state, log crash reports, etc.
Let's create a middleware for logging actions when they get dispatched.
const logger = (store) => (next) => (action) => {
console.log("DISPATCHED ACTION: ", action);
next(action);
}
This is one of the simplest middlewares you can create. It logs the actions and then forwards the call to the rest of the middlewares and reducers that are in the pipeline.
But how do we use our new created middleware?
? applyMiddleware
This method will take a bunch of middlewares and return an enhancer. And enhancers go into the createStore
function call.
import { applyMiddleware, createStore } from 'redux'
const logger = (store) => (next) => (action) => {
console.log("DISPATCHED ACTION: ", action);
next(action);
}
const store = createStore(rootReducer, applyMiddleware(logger));
Now, every time we dispatch an action we'll have a log in our console.
? With this we've covered out final method from Redux. That's all there is in the Redux API.
? I understand you won't get 100% of the things right now but it's good to be aware of all the tools you have under your belt.
? This article can act as guide for you when you want to revise something just before an interview or when you're implementing something. Save it!
? If you'd like to read more of my upcoming articles, you can connect with me on LinkedIn or Twitter.
? Thank you for reading!
This content originally appeared on DEV Community and was authored by Sanjeev Sharma
Sanjeev Sharma | Sciencx (2021-09-23T15:13:12+00:00) Just Redux: The Complete Guide. Retrieved from https://www.scien.cx/2021/09/23/just-redux-the-complete-guide/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.