DEV Community

loading...
Cover image for An Intro to Redux

An Intro to Redux

zhaluza profile image Zac Haluza ・15 min read

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

  1. Introduction
  2. Why Do We Need Redux?
  3. How Does Redux Work?
  4. Understanding Redux in an App
  5. Summary
  6. Next Steps
  7. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

Mockup-for-a-complicated-app

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',
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

We've just defined the basic business logic for our application. We already have some problems.

  1. Our App.js component is going to get crowded if we move it all there.
  2. It's going to get even more crowded if we start adding more state and logic to our app.
  3. 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.

redux-flow-diagram

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 to 1.
  • The store sends the updated state back to the UI. The counter now displays 1 instead of 0.

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.

redux-flow-diagram

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

The second contains a type property as well as a payload property.

{
  type: "SET_CUSTOM_COUNT",
  payload: 67
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

  1. The incrementCounter function (action creator) is executed.
  2. incrementCounter returns an object with a type property of INCREMENT_COUNTER. This object is our action.
  3. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 and action.
    • state - the initialState 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',
  };
};
Enter fullscreen mode Exit fullscreen mode
  • The incrementCounter action creator returns an object (an action) with a type property of INCREMENT_COUNTER.
{
  type: 'INCREMENT_COUNTER';
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode
  • The reducer looks at the action's type property and sees if it matches any of its cases. Bingo - we hit the INCREMENT_COUNTER case.
switch (action.type) {
  case 'INCREMENT_COUNTER':
    return {
      count: state.count + 1,
    };

  // other cases here...

  default:
    return state;
}
Enter fullscreen mode Exit fullscreen mode
  • The reducer returns an object with a single property, count. To calculate the value, it grabs the current value of count from the current state object (which is 0 now) and adds 1 to it.
{
  count: 1;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Still, we only need to look at one line:

const store = createStore(counterReducer);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Wrap our store around our entire app, using a special wrapper component called Provider.
  2. 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);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

This file suddenly got a lot more busy. The key difference is this chunk of code:

<Provider store={store}>
  <App />
</Provider>
Enter fullscreen mode Exit fullscreen mode

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 our count 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;
Enter fullscreen mode Exit fullscreen mode

At the top of the Counter component, we do two important things:

  1. Use the useSelector hook to access the value of the count property inside our store's state object, and then save it inside a constant named count.
  2. Invoke the useDispatch hook. The result, which we save as the constant dispatch, is a reference to the dispatch 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>
Enter fullscreen mode Exit fullscreen mode

We can also pass a payload to the action creator if needed:

<button onClick={() => dispatch(setCustomCount(419))}>
  Set Counter to 419
</button>
Enter fullscreen mode Exit fullscreen mode

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 and useDispatch 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!

Discussion (1)

Collapse
aadityasiva profile image
Aadityasiva

Nice just the thing I needed👍

Forem Open with the Forem app