Among the many libraries available to manage state in React, Redux is the most popular. But with this popularity has also come a reputation for having a steep learning curve.
In this post weβll be taking a look at what it takes to create a simple to-do list app using Redux, as well as exploring some of the additional features that Redux provides.
If you want to follow along, I have created a repository for the example app created in this guide at react-state-comparison.
This post assumes a knowledge of how to render components in React, as well as a general understanding of how hooks work. It also assumes you have read the previous post in the series on useReducer and React Context, as we will be making some comparisons to it here.
Installing Redux
To get started, weβll need to install both the redux
and react-redux
libraries. Use either of the following commands (depending on what package manager you are using):
yarn add redux react-redux
npm install redux react-redux
Getting up to speed
In the previous post in this series, we created a to-do list app using useReducer
and React Context that allows us to:
- Edit the name of the to-do list
- Create, edit and delete tasks
We will be re-creating that same example app in this post.
We also introduced the concept of a store, action, and reducer. As a little refresher:
- A store is a central location where we store all the state for our app.
- An action is in charge of telling the reducer to modify the store. We dispatch these actions from the UI.
- The reducer handles doing what the action tells it to do (i.e. making the necessary modifications to the store).
Defining your reducer
Defining a reducer in Redux will look very similar to the useReducer
hook. The only difference is that in Redux, we also pass in the initial state of our app through the reducer.
// src/redux/state/reducers
export const reducer = (state = initialState, action) => {
const { listName, tasks } = state;
switch (action.type) {
case 'updateListName': {
const { name } = action.payload;
return { listName: name, tasks }
}
default: {
return state;
}
}
};
If you havenβt seen something like
state = initialState
before, itβs whatβs known as a default parameter in JavaScript. What weβre saying here is that if the state parameter is undefined, use initialState.
The initial state will look something like this:
const initialState = {
listName: 'My new list',
tasks: {},
};
One final note on the reducer is to never directly modify the state object that we receive. e.g. Donβt do this:
state.listName = 'New list name';
We need our app to re-render when values in our store are changed, but if we directly modify the state object this wonβt happen. As the shape of your store gets more complicated, there are libraries like immer that will be able to do this for you.
Creating and initialising our store
Next, you can create your Redux store using your reducer:
// src/redux/state/store
import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import reducer from '../reducers';
const store = createStore(reducer);
export const TasksProvider = ({ children }) => (
<Provider store={store}>{children}</Provider>
);
Wrapping our app with the Provider
To make use of our store, we need to create our React app in our src/redux/components
folder, and wrap it in the TasksProvider
:
// src/redux/components
import React from 'react';
import { TasksProvider } from '../state/store';
import Name from './name';
import Tasks from './tasks';
import CreateTask from './create-task';
const ReduxApp = () => (
<>
<h2>Redux</h2>
<TasksProvider>
<Name />
<Tasks />
<CreateTask />
</TasksProvider>
</>
);
export default ReduxApp;
Fetching data using selectors
With useReducer
, we always grab the entire state object, and then get what we need from it (e.g. by doing state.tasks
).
In Redux, we use selectors to fetch only the data that we need from the store.
To get the list of tasks from your store, you would create a tasksSelector
:
// src/redux/state/selectors
export const tasksSelector = (state) => state.tasks;
We use these selectors with the useSelector
hook:
import React from 'react';
import { useSelector } from 'react-redux';
import { tasksSelector } from '../../state/selectors';
import TasksView from '../../../common/components/tasks';
import Task from '../task';
const Tasks = () => {
const tasks = useSelector(tasksSelector);
return <TasksView Task={Task} tasks={tasks} />;
};
export default Tasks;
Why do you need selectors?
If the Tasks
component took in the entire state
object and got the tasks data via state.tasks
, React will re-render the Tasks
component each time any part of the state changed.
By using a selector, Tasks
will re-render only if the state.tasks
data changes. If we changed the name of the list, for example, this would no longer cause the Tasks
component to re-render.
Dispatching an action
Dispatching actions will also look pretty identical to how we do it with useReducer
. Here we use the useDispatch
hook to dispatch an action.
// src/redux/components/name
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import NameView from '../../../common/components/name';
import { nameSelector } from '../../state/selectors';
const Name = () => {
const dispatch = useDispatch();
const listName = useSelector(nameSelector);
const onSetName = (name) =>
dispatch({ type: 'updateListName', payload: { name } });
return <NameView name={listName} onSetName={onSetName} />;
};
export default Name;
After defining your actions, reducer, store and selectors, your state management setup will be complete!
Redux vs useReducer
Weβve now reached the same point as we did in the previous post on useReducer
. Youβll notice that there actually isnβt that much difference in the code we've written.
As your app gets bigger, you will start using some of the additional features that Redux provides, and this is where the complexity can start to creep in.
Moving your actions to a separate file
In larger apps, you would define your actions in a separate file (or files) as constants:
// src/redux-advanced/state/actions
export const UPDATE_LIST_NAME = 'UPDATE_LIST_NAME';
One of the reasons we do this is it prevents you from making any typos when referring to your actions. Having it in one place makes things easier to see all the actions your codebase has, and makes it easier to follow naming conventions when creating new actions.
On top of defining your actions as constants, there is also the concept of action creators. These are functions that will create the actions for you:
export const updateListName = (name) => ({
type: UPDATE_LIST_NAME,
payload: { name }
});
It allows you to simplify your code from this:
dispatch({ type: UPDATE_LIST_NAME, payload: { name } });
To this:
dispatch(updateListName(name));
Defining actions and action creators makes your codebase more maintainable, but it comes at the cost of writing extra code.
Splitting out your reducer
As you add more functionality to your app, your reducer file is going to get bigger and bigger. At some point, you will probably want to split it out into multiple functions.
Going back to the to-do list example, our store contains listName
and tasks
:
{
listName: 'My new list',
tasks: {},
}
We could split our reducers into one for listName
and one for tasks
. The one for listName
would look like this:
// src/redux-advanced/state/reducers/list-name
import { UPDATE_LIST_NAME } from '../actions';
const initialState = 'Default name';
const reducer = (state = initialState, action) => {
switch (action.type) {
case UPDATE_LIST_NAME: {
const { name } = action.payload;
return name;
}
default: {
return state;
}
}
};
export default reducer;
The state passed into the above function only contains listName
. We would also create a separate reducer for tasks
.
We then combine these two reducers using the combineReducers
function:
// src/redux-advanced/state/reducers
import { combineReducers } from 'redux';
import listNameReducer from './list-name';
import tasksReducer from './tasks';
const reducer = combineReducers(listNameReducer, tasksReducer);
export default reducer;
The connect function
In Redux today, you can use useDispatch
to dispatch actions, and useSelector
to get data from your store. Before React Hooks came along, all Redux apps instead used a function called connect
.
You can wrap this connect
function around your components and it passes in (as props):
- The data that you need from selectors (using
mapStateToProps
) - Functions that will dispatch actions (using
mapDispatchToProps
)
Here we've wrapped connect()
around our Name
component:
// src/redux-advanced/components/name/index.js
import { connect } from 'react-redux';
import { nameSelector } from '../../state/selectors';
import { updateListName } from '../../state/actions';
import Name from './view';
const mapStateToProps = (state) => ({
name: nameSelector(state),
});
const mapDispatchToProps = (dispatch) => ({
updateListName: (name) => dispatch(updateListName(name))
});
export default connect(mapStateToProps, mapDispatchToProps)(Name);
mapStateToProps
mapStateToProps
takes in the entire state object as its argument. Using selectors, you can return any values that your component needs. In our case, we needed the list name value from our store. This value will be available as a prop in our Name
component.
mapDispatchToProps
mapDispatchToProps
takes in a dispatch function as its argument. Using it, we can define a function that will dispatch an action. This will also be available as a prop in our Name
component. mapDispatchToProps
can also be simplified to this shorthand version:
const mapDispatchToProps = {
updateListName,
};
The βviewβ component
connect()
allows you to put all your state management in one file, and lets you have a βviewβ file where all you have to focus on is how the component is rendered:
// src/redux-advanced/components/name/view.js
import React from 'react';
import NameView from '../../../common/components/name';
const Name = ({ name, updateListName }) =>
<NameView name={name} onSetName={updateListName} />;
export default Name;
The component no longer has to worry about dispatching actions or using selectors, and instead it can use the props it has been given.
Is connect() still useful?
Just because we have hooks today doesnβt render connect()
obsolete. On top of being useful for separating your state management from your βviewβ component, it can also have some performance benefits too.
Right now our Tasks
component:
- Gets all tasks using
tasksSelector
- Loops through each one to render individual
Task
components
This means that when using Redux hooks, if you edit one task, all tasks will re-render.
With connect()
, you can pass through components in mapStateToProps
. In the connect function for our Tasks
component, we can pass through Task
:
// src/react-advanced/components/tasks/index.js
import { connect } from 'react-redux';
import { tasksSelector } from '../../state/selectors';
import Task from '../task';
import Tasks from './view';
const mapStateToProps = (state) => ({
Task,
tasks: tasksSelector(state),
})
export default connect(mapStateToProps, null)(Tasks);
Components that have been passed through mapStateToProps
will only re-render if they need to. In our case, this means that if we edit a task, only that individual task will re-render.
If you want to read more about the pros and cons of connect()
vs Redux hooks, I recommend checking out this article on useSelector vs connect.
The Redux Toolkit
Redux is known for being verbose and having a lot of boilerplate code. A good example of this is how you define actions and action creators. You go from one line:
dispatch({ type: 'updateListName', payload: { name } });
To more than five:
// Actions file
export const UPDATE_LIST_NAME = 'UPDATE_LIST_NAME';
export const updateListName = (name) => ({
type: UPDATE_LIST_NAME,
payload: { name }
});
// Usage
dispatch(updateListName(name));
Defining your actions and action creators in a separate file increases the simplicty of your UI code and reduces the possibility of bugs. But the tradeoff is that each time you want to add a new feature to your app, you have to write more code upfront.
The Redux Toolkit is Reduxβs response to address some of these boilerplate concerns. It provides useful functions to try and simplify the code that you write. For instance, the createAction
reduces creating actions back down to only two lines of code:
// Defining your action
const updateListName = createAction('updateListName');
// Using your action
dispatch(updateListName({ name }));
To see what other features the Redux Toolkit provides, Iβd recommend checking out their Basic Tutorial.
The Redux DevTools Extension
As one last thing, the Redux DevTools Extension (available on browsers like Chrome and Firefox) is an insanely useful tool for debugging your React + Redux app. It lets you see in real-time:
- When actions are fired
- What changes to your store are made as a result of these actions being fired
If you're looking to develop apps with Redux, I would highly recommend that you check it out.
Conclusion
Building a to-do list app using Redux is quite similar to React's useReducer
hook. However if youβre working on larger apps (or apps that existed before hooks) youβll probably have to wrap your head around functions like combineReducers()
and connect()
too. If youβre looking to reduce boilerplate, the Redux Toolkit looks like a promising way to reduce the amount of code you need to get started with Redux.
I learnt Redux fairly on in my career (actually I learnt it at the same time I learned React) and although I struggled to get my head around the concepts at first, I really grew to be quite fond of it! I hope this post has made things a little bit easier to understand, but if you have any questions, please let me know.
To check out any of the code that weβve covered today, Iβve created two apps:
- redux - Redux with hooks
-
redux-advanced - Redux with
connect()
andcombineReducer()
Thanks for reading!
Top comments (9)
Nice post Emma. Just one thing I would like to mention is while using mapStateToProps we may also have a need to use the props of the connected class. we can get those by using
ownProps
as a second argument in the function as below.const mapStateToProps = (state, ownProps) => ({
name: nameSelector(state ?? ownProps),
});
Thanks! That's a good point to call out.
Really nice post! I love how Redux is such a powerful library π―
Thankyou! It's sad that it's gotten a bit of a bad rep, but it is understandable too. It will be interesting to see how Redux Toolkit is adopted.
Yes, it did go down a bit when Context API/Hooks were introduced. But I still think Redux is way more systematic in state management approach, especially for scalable projects.
Oh, don't know much about Redux Toolkit, do you have an article about it? Love your explanations. π
The Redux docs do provide a tutorial on Toolkit. It essentially it simplifies the store creation, action creation and the reducer creation - I didn't have enough time to dive too deep into it, but maybe I'll do a post on it in the future π
Awesome! Thanks :) I'll also take a look (and maybe write an article later π).
Super useful post again! I appreciate that you included splitting of reducers. Most react-redux tutorials don't have that. Thanks!
Thanks for commenting! I'm glad to hear that π