This article was originally published at haluza.dev
What you'll get out of this article:
- Learn why developers use external libraries to manage state in React
- Understand the fundamentals of Redux
- Apply Redux concepts to a simple counter app
- Learn how Redux Toolkit simplifies Redux setup
This article is for you if:
- You're familiar with the basics of React
- You know how to manage React state with hooks and/or state objects
- You're new to state management libaries like Redux and MobX
If you're wondering why this article discusses vanilla Redux and not Redux Toolkit, please read my explanation in the afterword.
Table of Contents
- Introduction
- Why Do We Need Redux?
- How Does Redux Work?
- Understanding Redux in an App
- Summary
- Next Steps
- Afterword: Why This Article Uses Vanilla Redux
Introduction
State management is one of the core concepts of React. It's also one of the most complicated. This isn't necessarily because managing state in React is tricky; rather, there are so many different ways to do it!
In this article I'm going to assume that you're comfortable managing state within a component, but are relatively new to Redux.
At the simplest level, Redux lets you do two things:
- Manage state from a single location in your app
- Access this state anywhere in your app, without passing it from component to component
To understand why this is so important, let's take a moment to imagine we've been hired to create a new hit app.
Why Do We Need Redux?
Our product manager wants us to build an app called Counter. It's fast, sleek, and consists of a single component. (Think of how small the bundle size is!)
Check out the code below, or click here to view this as an app on CodeSandbox.
export default function App() {
const [count, setCount] = useState(0);
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
const decrement = () => {
setCount((prevCount) => prevCount - 1);
};
const reset = () => {
setCount(0);
};
return (
<div className="App">
<h1>Counter - No Redux</h1>
<div className="counter">
<button onClick={decrement}>-</button>
{count}
<button onClick={increment}>+</button>
</div>
<button onClick={reset}>Reset</button>
</div>
);
}
Inside this tiny App
component, we're creating a single count
state for our counter, initializing it to 0
, and defining methods to increment
, decrement
, and reset
it.
Then we're implementing the counter inside the same component.
If your React apps are all as simple as this one, you'll never need to use a state management solution like Redux. However, I can all but guarantee that you'll work on an app in which useState
or setState
alone won't cut it.
Example 2: Complex Counter
Turns out our counter app was massively popular — it's time to introduce the
world to Counter 2.0!
Here's the mockup our product manager just gave us. Note that it's a little more complicated than what we were working with before:
To save you some stress, we aren't going to code this app out. Instead, I want you to think of the different types of state that we would need to manage inside this app. Off the top of my head, here are the key types of state we would need to manage:
- All of the counters in the app, as well as their current values. We could store the counter values inside an array to keep track of the counters more easily.
- Login-related info, such as the user's name, so we could display it in the UI.
- The current color theme (light mode or dark mode)
Previously, we stored all of our state logic inside our App.js
file. Now, however, our state is a little bigger. Below you'll see our current state represented as an object. Why did I use an object? Keep that question in mind as you read on.
const initialState = {
username: '',
counters: [0, 17],
colorTheme: 'light',
};
Well, that doesn't seem so bad. But hold on — don't we also need to include methods to trigger state changes?
const setUsername = (username) => {
// logic to set the username when someone logs in
}
const addCounter = () = => {
// logic to add a counter
}
const removeCounter = (index) => {
// logic to remove a counter at a certain index
}
const increment = (index) => {
// logic to increment a specific counter
}
const decrement = (index) => {
// logic to decrement a specific counter
}
const reset = (index) => {
// logic to reset a specific counter
}
We've just defined the basic business logic for our application. We already have some problems.
- Our
App.js
component is going to get crowded if we move it all there. - It's going to get even more crowded if we start adding more state and logic to our app.
- We'll also need to pass our state and methods down to our components. And if
we nest components inside other components (for example,
App
->CounterContainer
->Counter
), we run the risk of introducing prop drilling into our app.
Wouldn't it be easier if we had one central place to store our state and our state-related methods, like adding counters and changing the color theme? And wouldn't it also be great if we could grab state and methods directly from this central store, instead of passing them through component after component?
This is where Redux comes in.
How Does Redux Work?
Counter 2.0 shows us some very common state management issues that can occur in
React apps when they grow more complex. Redux helps solve these problems by
handling state management in a very opinionated and clearly defined flow.
Here's how Redux's "one-way data flow" works. Just soak it in — it's OK if it doesn't make sense yet.
Let's translate this image into a series of written steps. For now, let's imagine that we've implemented Redux inside a simple counter app, like Counter 1.0.
This is what happens when a user clicks on the button to increment the counter from 0
to 1
.
- The app dispatches an action. The action is a function called
increment
. - The action is sent to the store, which holds the app's state inside an object.
- The store updates the state using a reducer function (more on that
later).
- In this case, the
count
state is increased to1
.
- In this case, the
- The store sends the updated state back to the UI. The counter now displays
1
instead of0
.
Actions, stores, reducers... This is getting extremely abstract. To make these concepts more tangible, let's see an how Redux works inside a React app.
Understanding Redux in an App
Remember Counter 2.0? Our product manager decided to scrap it because it was too complicated. Now they want us to build the much simpler and much prettier Counter 3.0. Oh, and they want us to use Redux!
Here's what the finished app looks like. Before moving on, poke around inside the app and get a feel for its functionality. Inside the redux
directory, you'll find some files with familiar sounding names, like reducer.js
, actionCreators.js
, and store.js
.
We're going to explore the following concepts inside the Counter 3.0 app:
- Reducers
- Actions (and action creators)
- Store
Let's take a look at that Redux flow diagram again. It's important to keep these concepts in mind as you explore the app.
Actions & Action Creators
Before I explain what an action or an action creator is, let's look at a simplified version of the actionCreators.js
file.
export const incrementCounter = () => {
return {
type: 'INCREMENT_COUNTER',
};
};
export const decrementCounter = () => {
return {
type: 'DECREMENT_COUNTER',
};
};
export const resetCounter = () => {
return {
type: 'RESET_COUNTER',
};
};
export const setCustomCount = (customCount) => {
return {
type: 'SET_CUSTOM_COUNT',
payload: customCount,
};
};
Here we've created functions to define four events we can trigger with our app:
- Increment the count
- Decrement the count
- Reset the count
- Set the count to a custom number
Each of these events corresponds to a button in the app.
These functions are called action creators. Each action creators returns an object called an action.
There are two basic types of actions.
The first contains only a type
property. Think of it as the action's
label.
{
type: 'INCREMENT_COUNTER';
}
The second contains a type
property as well as a payload
property.
{
type: "SET_CUSTOM_COUNT",
payload: 67
}
The name payload
is an apt description. It's the value(s) we want to use when we update the state. In the case of our SET_CUSTOM_COUNT
action, we're updating the count
state to 67
.
Why don't any of our other actions contain payloads? Simple: they don't need them. We'll see why when we learn about reducers next.
Where do we trigger our reducers? Right inside the app. Here's the code for our "increment" button:
<button onClick={() => dispatch(incrementCounter())}>+</button>
We'll discuss the dispatch
method later. But in a nutshell, here's what happens when a user clicks the +
button to increment the counter.
- The
incrementCounter
function (action creator) is executed. -
incrementCounter
returns an object with atype
property ofINCREMENT_COUNTER
. This object is our action. - The action is sent to the reducer.
Reducer
This is where it starts to come together.
What's the reducer? It's simply a function that controls your app's state.
It's often written as a switch statement, as is the one in this app, but that's simply a common convention, not a requirement.
Here's what our reducer looks like:
const initialState = {
count: 0,
};
export default function counterReducer(state = initialState, action) {
switch (action.type) {
case 'INCREMENT_COUNTER':
return {
count: state.count + 1,
};
case 'DECREMENT_COUNTER':
return {
count: state.count - 1,
};
case 'RESET_COUNTER':
return {
count: 0,
};
case 'SET_CUSTOM_COUNT':
return {
count: action.payload,
};
default:
return state;
}
}
That's a lot to take in. Let's walk through this chunk of code step by step.
- First, we define our
initialState
as an object above the reducer. - Next, the reducer function accepts two parameters:
state
andaction
.-
state
- theinitialState
object is this parameter's default value. -
action
- this refers to whatever action that was just returned by the action creator.
-
- We create a switch statement. Inside this statement, we return an object depending on the action's type property.
If a user opens the app and chooses to increment the counter, what happens?
- The app dispatches the
incrementCounter
action creator:
const incrementCounter = () => {
return {
type: 'INCREMENT_COUNTER',
};
};
- The
incrementCounter
action creator returns an object (an action) with atype
property ofINCREMENT_COUNTER
.
{
type: 'INCREMENT_COUNTER';
}
- Our reducer function is invoked, accepting
initialState
and the action object as parameters. In pseudocode, it looks something like this:
const initialState = {
count: 0,
};
const incrementAction = { type: 'INCREMENT_COUNTER' };
counterReducer(initialState, incrementAction);
- The reducer looks at the action's
type
property and sees if it matches any of its cases. Bingo - we hit theINCREMENT_COUNTER
case.
switch (action.type) {
case 'INCREMENT_COUNTER':
return {
count: state.count + 1,
};
// other cases here...
default:
return state;
}
- The reducer returns an object with a single property,
count
. To calculate the value, it grabs the current value ofcount
from the current state object (which is0
now) and adds1
to it.
{
count: 1;
}
Hold on — that looks a lot like our initialState
object!
// Our initial state object
const initialState = {
count: 0,
};
// The object returned by the reducer
{
count: 1;
}
That's right. The reducer returns the updated state. In more technical terms, it replaces the previous state object with a new state object containing updated values. This is because Redux state is immutable (key interview term!). You should never directly modify your Redux state inside your reducer. Instead, you should return a brand new object, like we do here.
This updated state object is now available for our app to use. But how does our app have access to the state?
It's time to learn about the store.
Store
Here's what Counter 3.0's store looks like. Brace yourself... it's 4 lines of code.
import { createStore } from 'redux';
import counterReducer from './reducer';
const store = createStore(counterReducer);
export default store;
Still, we only need to look at one line:
const store = createStore(counterReducer);
A Redux store is simply an object that holds your app's state. Your app
should only contain one store. This is a HUGE part of what makes Redux an appealing state solution. Your store becomes a single source of truth for your app's state.
Remember the phrase "single source of truth." It's an easy way to sum up the benefits of Redux. Plus, it's another great phrase to use in interviews.
In the line of code above, Redux's createStore
function takes in your reducer and uses it to construct the store object.
As your app grows more complex, you may want to create multiple reducers. If we add a to-do feature to our counter app, creating a separate toDoReducer
where
we store our state and methods for our app's "to-do" functionality.
Fortunately, the Redux library provides a combineReducers
function that lets you feed a multilayered reducer to your store.
We're almost there! We've built our action creators, reducer, and store. Now we just need to give our app access to the store and the state inside it.
Connecting the App to the Store
There are only two steps left:
- Wrap our store around our entire app, using a special wrapper component called
Provider
. - Hook our components into the store with... Redux hooks!
Hang in there. This is the home stretch!
Wrapping the Store Around Our App
For these last few steps, we're going to use a few features that the React Redux library gives us. The first one is called Provider
, and it's a component that we wrap around our entire app. We use it in the index.js
file.
Here's the index.js
file of a typical React app.
import ReactDOM from 'react-dom';
import App from './App';
const rootElement = document.getElementById('root');
ReactDOM.render(<App />, rootElement);
Here's what the same file looks like when we implement the Provider
component.
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './redux/store';
import App from './App';
const rootElement = document.getElementById('root');
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
rootElement
);
This file suddenly got a lot more busy. The key difference is this chunk of code:
<Provider store={store}>
<App />
</Provider>
We're providing the entire app with access to our Redux store. And this is a big thing. It means that regardless of where we are in our app — even if we're inside a component nested a dozen layers down — we can reach directly into the store without even leaving that component.
We no longer need to pass down all our state as props.
Accessing State From Inside a Component
Finally, let's look at two hooks: useSelector
and useDispatch
.
-
useSelector
lets us access state values inside our store (like ourcount
state). -
useDispatch
lets us "dispatch" action creators to our reducer. In other words, it lets us trigger state changes, like incrementing a counter.
Think of useSelector
as a noun (e.g. count
) and useDispatch
as a verb (e.g. incrementCounter
).
Inside our app's Counter.js
file, we implement both of these hooks.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
incrementCounter,
decrementCounter,
resetCounter,
} from '../redux/actionCreators';
const Counter = () => {
const count = useSelector((state) => state.count);
const dispatch = useDispatch();
return (
<div className="counter">
<div className="counter-top">
<button onClick={() => dispatch(decrementCounter())}>-</button>
<p>{count}</p>
<button onClick={() => dispatch(incrementCounter())}>+</button>
</div>
<button onClick={() => dispatch(resetCounter())}>Reset</button>
</div>
);
};
export default Counter;
At the top of the Counter
component, we do two important things:
- Use the
useSelector
hook to access the value of thecount
property inside our store'sstate
object, and then save it inside a constant namedcount
. - Invoke the
useDispatch
hook. The result, which we save as the constantdispatch
, is a reference to thedispatch
function in the Redux store.
That's all we need to work with our store!
For the useDispatch
hook, we do need to import any actions we're going to use, so we can invoke it as such:
<button onClick={() => dispatch(incrementCounter())}>+</button>
We can also pass a payload to the action creator if needed:
<button onClick={() => dispatch(setCustomCount(419))}>
Set Counter to 419
</button>
And...that's it! We've hooked our app up to our Redux store.
Here's the link to the finished app, in case you don't want to scroll all the way back up to the sandbox.
And here's the code!
For a more detailed look at useSelector
and useDispatch
, please refer to the React Redux documentation:
Summary
We covered a massive amount of ground in this article.
Here are the key concepts we covered:
- Redux is a state management library that acts as the single source of truth for your app's state-related logic.
- To implement Redux, you should implement the following in your app:
- Action creators: functions that are dispatched when your app triggers an action.
- Every action creator returns an action, an object with instructions for updating the state.
- Reducers: functions that take a state object and action as parameters, and return an object containing the app's updated state.
- Store: An object containing the entirety of your app's Redux state.
- To give your app access to the store, wrap it inside a
Provider
component. - Use the
useSelector
anduseDispatch
hook to access state and dispatch action creators from inside any component inside your app.
If you're feeling lost, that's normal. It took me at least three separate tries to understand Redux well enough to implement it in a tiny app.
If you're having trouble with these concepts, take some time to check out the excellent explanations provided in the official Redux documentation.
Next Steps
As you're getting more comfortable with Redux, I highly recommend that you do the following:
Read "You Might Not Need Redux"
Dan Abramov is famous for creating Redux and working on Create React App and React hooks. He also wrote a very insightful article called
You Might Not Need Redux.
Redux is a great tool to have, but it's just that — a tool. You shouldn't use it if you don't need it. For smaller apps, React state may be enough. For larger apps, you may find yourself using a mixture of Redux state for data used globally and React state for more localized state.
Build an app with Redux
I want you to implement Redux in a React app. I recommend keeping the app as simple as possible; this will let you focus more on the implementation of Redux, as opposed to React itself.
Some ideas:
- Build a score counter for a sports game (any sport of your choice). Give users the option to add points for either team. You can even include a winning condition (one team wins when they attain a certain number of points).
- Build your own counter, using Counter 3.0 (the one we just finished going over) as a reference.
- Up for a challenge? Create a simplified ecommerce app with a shopping cart that displays items as you click on them.
Feel free to use this sandbox as a reference. It's our counter from before, to include some best practices that are explained in the comments.
Explore Redux Toolkit
I mentioned Redux Toolkit at the very beginning of this post. Once you're comfortable with how Redux works, you should make an effort to move to Redux Toolkit. It simplifies a lot of the code that we just wrote. After working with vanilla Redux, you'll see the benefits immediately.
Redux Toolkit was built by the Redux.js team and is described as "the official, opinionated, batteries-included toolset for efficient Redux development" on the library's site.
As someone who cut their teeth on Redux and then moved to Redux Toolkit, trust me when I say it's the way that any team should work with Redux logic.
But wait - if Redux Toolkit is the modern Redux implementation you should use, why did we spend an entire article using vanilla Redux?
Afterword: Why This Article Uses Vanilla Redux (Instead of Redux Toolkit)
I believe that the basic Redux.js library provides the most direct way to learn how Redux works. With Redux Toolkit, you're able to leverage many new APIs that improve on Redux's functionality. However, to really grasp what these improvements are doing, and why they're so important, you need a firm understanding of how Redux works.
For instance, Redux Toolkit's createSlice
API is one of my favorite features, as it removes the need to create a separate file for your action creators - it automatically generates them from your reducer. To really understand how powerful this is, you should have a solid understanding of what action creators and actions are.
In other words:
- Vanilla Redux lets you learn Redux with the smallest amount of abstractions
- Redux Toolkit builds on the original Redux library with more powerful APIs, and you should use it once you understand how Redux works
It's also worth mentioning that some teams with older codebases may still be using the older version of Redux, just as many React codebases will feature
class-based state instead of hooks (or a mixture of the two). While this shouldn't be your motivation for learning vanilla Redux, it's definitely a side benefit that makes you more versatile.
We've covered so much knowledge in this post. Take a break and let it sink in before you do anything else!
Top comments (1)
Nice just the thing I needed👍