How to manage state in a React app using Redux.

Emmanuel Fordjour Kumah - Sep 15 '23 - - Dev Community

In this tutorial, you will manage the state of a React app using Redux. Redux helps you track and manage the state of an entire application in a single object instead of having the state and logic in a top-level component.

You will build a to-do app that centralizes the state and logic using Redux.

By the end of the tutorials you will know:

  • What Redux is and the benefit of using Redux for state management.

  • Understand and use Redux concepts such as the store, reducer, actions, etc. in a Todo app.

The tutorial will be in two sections. The first section explains key concepts, the Redux architecture, and the basic usage of Redux. In the next section, we will build a Todo app using Redux for state management.

Prerequisite

To get the most out of this tutorial you should be familiar with:

  • Functions in JavaScript

  • Knowledge of React terminology: State, JSX, Components, Props, and Hooks

  • Building a basic React app

Introduction to State Management

React enables developers to build complex user interfaces easily. To add interactivity to the UI, React components need access to data. The data can be a response from an API endpoint or defined within the app. This data will be updated in response to an interaction, such as when a user clicks on a button, or types into an input field.

Inside a React component, the data is stored in an object called state. Whenever state changes, the component will re-render, and React will update the screen to display the new data as part of the UI.

In a React app, multiple components may need access to the state. Hence, it needs to be effectively managed. Effective state management entails being able to store and update data in an application.

What is Redux?

Redux is a pattern and library for managing and updating application state, using events called "actions". It serves as a centralized store for state that needs to be used across your entire application, with rules ensuring that the state can only be updated in a predictable fashion.

With Redux, you have a central store to keep, update, and monitor the state of your application. That means, our components may not have states. The state will be in a central location and can be accessed by multiple components in your application.

What problem does Redux solve?

A basic React app can be segmented into the following:

  • State: The current condition of the app

  • View: The UI of the app

  • Actions: A function that updates the state when an event occurs in your app (generally referred to as Event handlers).

Every component in an app can have a state. However, it becomes a challenge if multiple components need access to the same data. To solve this issue, we "lift the state up". Lifting state up is a process where you move the state from a child component to its parent (top-level) component. With this approach, you can easily share state between multiple child components.

LiftingStateReact

However, there are disadvantages to "lifting state up":

  • It can complicate your code: Lifting the state up can add huge boilerplate code to your components. The state must be passed down from the parent component to the child components resulting in prop-drilling. Additionally, the state is updated in the parent component.

  • It can impact performance: When you lift the state up, you increase the number of components that will re-render when the state changes. This can affect performance, especially on mobile devices.

In a large-scale single-page application, our code will manage more states. This state can include server responses and cached data, as well as locally created data that has not yet been persisted to the server. It will get to the stage where you will lose control over when, why and how the state updates. Making it difficult to reproduce bugs or add new features.

A better approach is to extract the shared states from the parent component and put them into a centralized location outside the component tree.

This is a better approach because:

  • It eliminates prop drilling.

  • Regardless of the component position, you can trigger actions from any component inside the parent component, and the state can be modified.

That is the concept behind Redux. It helps developers manage global state (a state that is needed across many parts of our app), and make the state accessible by all components irrespective of how deeply nested they are.

The first rule of Redux is that everything that can be modified in your application should be represented in a single object called the state or the state tree.

There are key terms to know when using Redux. To make it easy to understand these terms we will first consider an analogy for Redux. After this, we will define the terms in the next sections.

Redux analogy

Imagine you are the Managing Partner of a huge restaurant. To be well versed in managing the restaurant, you decide to keep track of the state of the restaurant.

You might want to track:

  • The stock of the various ingredients available

  • Financial status (weekly income and expenditure pattern)

  • The number of employed chefs

  • The number of hired waitresses

  • Weekly customers etc

To keep all this information in your brain will be a hassle. Instead, you keep them in a central location called the store (redux store).

You hire an attendant (reducer) who is the only person who can update the store's information.

There are shareholders (components) who rely on the state(data) of the restaurant to update their portfolio (UI). These shareholders can only access data and cannot modify it.

Now let us assume the shareholders hire a new chef and the store's data need to be updated. Because they cannot update the data, a shareholder can send (dispatch) a note with that new information to the attendant(reducer). He then updates the previous data in the store with the latest information.

Anytime data is updated, the rest of the shareholders are notified (subscribers), and they can update their portfolio (UI)

ReactRedux

Understanding the Redux Terminologies

Below are the new terms to be familiar with:

Actions

The second rule of Redux is that the state is read-only. You can only modify the state tree by sending an action. This ensures that neither the views nor the network callbacks will ever write directly to the state. Instead, they express an intent to transform the state.

In other words, action is the only recommended way to change the application state.

An action describes what has occurred in the application. It is a JavaScript object passed as a parameter to the store and holds the information required for the store to update the state.

An action varies in any given application. For instance, in a counter app, you will only need the following actions:

  • increased the count

  • decreased the count

In a todo app, you may have the following actions:

  • Added todo item

  • Deleted todo item

  • Complete todo item

  • Filter todos, etc.

With Redux, because we are separating the state from the components, the components don't know exactly how the state changes. All they care about is that they need to send an action.

The action object has a type property where you specify what event occurred in your component. That means whenever an event occurs, the event handler function will dispatch an action with what has occurred to help update the state in the store.

The action object also has a payload property. Any new data about the event will be in the payload. For instance, when I dispatch an action of type "addedTodo", the payload can contain the new to-do item and the ID.

Below are examples of action objects:


//action 1
const addTodoAction = {
  type: 'todos/todoAdded', //what happened
  payload: {todoID, newTodo} //data
}
//action 2
const getOrder = {
type: 'orders/getOrderStatus', //what happened
payload: {orderId, userID} //data
}
Enter fullscreen mode Exit fullscreen mode

Action Creators

The action creators are functions that return action objects. Because the action creators contains the logic that can be used in multiple instances of the application, you can pass it some parameters that can be accessed in the action objects.

Below are examples of action creators:

//example 1
function addTodo(todo){
//return action object
return {
  type: 'todos/addTodo',
  payload: todo // what happened
    }
}
//example 2
function getOrders(orderID, userID){
//return action object
  return {
       type: 'orders/getOrderStatus',
       payload: {orderId, userID} //what happened
   }
}
Enter fullscreen mode Exit fullscreen mode

Reducers

A reducer is a pure function that accepts the current state and an action as arguments and returns the updated state. It is called a reducer because similar to the Array.reduce() method, the Redux reducer reduces a set of actions over time into a single state.

The reducer should be a pure function. A pure function is a function that will return the same output for the same input. It does not change or modify the global state or the state of any other functions.

What this means is :

  • A reducer function is not allowed to modify the current state. Instead, they must make a copy of the current state and update the copied values.

  • A reducer function should not update the state by reading from a database

  • A reducer function should not make a call to any third-party API

Below is the syntax of a reducer function:

const myReducer = (state, action) => newState
Enter fullscreen mode Exit fullscreen mode

The logic inside the reducer function is as below:

  • In the body of the function, we check the action.type property.

  • If the type of action matches something you have defined, you will make a copy of the state, and modify that state with the new value from the action.payload

  • If the action.type does not match anything you have defined, you will return the existing state

Below is an example of a todoReducer function:

const intialTodo = [{id:1, todo:""}]

const todoReducer = (state = initialTodo, action)=>{
if(action.type === "todos/AddedTodo"){
  return [...state, todo: action.payload]
}else{
return state
 }
}
Enter fullscreen mode Exit fullscreen mode

Below is what is happening:

  • In the if statement verify if the action.type matches the expression on the right (action.type === "todos/AddedTodo")

  • If it does, then we use the spread operator (...) to make a copy of the state , and update the copy with the new todo data derived from action.payload

  • If not, we return the previous state

The third principle of Redux is that to describe state changes, you declare a function that accepts the previous state of the app, the action being dispatched, and returns the next state of the app

Store

A store in an object that holds the entire state of your application. It is the central location of data and where data is updated.

The store has three main methods:

  • getState(): Returns the current state of the application

  • dispatch(action): This is how to instruct the component to send an action to the store to change the state of the application.

  • subscribe(listener): The subscribe method will allow the components to listen for a change in data. It accepts a listener as a callback function that helps you:

    • Update the UI to reflect the current state
    • Perform side effects or any other task that needs to be done when the state changes.

Below is an example of how to create a store in redux.

//store.js

//import your root reducer
import rootReducer from "./rootReducer"
//import createStore from redux
import {createStore} from "redux"

//create the store
const store =createStore(rootReducer)
Enter fullscreen mode Exit fullscreen mode

Dispatch

Dispatch is used to send action to our store . It is the only way to change the state of our app.

To update the state , you will call the store.dispatch() method. When dispatch() is called, the store will execute the reducers (the reducers have access to the current state and an action as input, and perform some logic). The store then updates its state with the output of the reducers.

The store.dispatch() method accepts the action object as an argument.

store.dispatch({ type: 'todo/addedTodo' })
Enter fullscreen mode Exit fullscreen mode

In the snippet above, for instance, whenever a user enters a new to-do, you will dispatch the action to the store. Because there is a reducer function inside the store, it will use the dispatched action.type to determine the logic for the new state.

Selectors

Selectors are functions that help you extract specific data from the state. It accepts the state as an argument and returns the data to retrieve from the state.

You will use the selector in your component to get specific data from the state.

const selectLatestTodo = state => state.data //selector function

const currentValue = selectLastestTodo(store.getState())
console.log(currentValue)
// Buy milk
Enter fullscreen mode Exit fullscreen mode

Illustration of the Redux architechture

In this section, we will use all the terminologies learned to explain how data flow in our app, and how the UI is re-rendered when the state changes.

Let's take a look at the setup of Redux:

  • Create a redux store

  • Define the reducer logic and pass the reducer function to the store. The reducer accepts the state and action object as arguments.

  • The store will run the logic in the reducer function

  • The value returned by the reducer function becomes the initial state of the app.

  • When the component is mounted, it connects with the store, gets the initial state, and uses the state to display the UI. Because the component is connected to the store, it will have access to any state update.

Updating the state:

  • An event occurs in the app. For instance, a to-do item has been added

  • The component dispatches the action to the redux store

  • The store re-runs the reducer function. It has access to the previous state, the action object, and returns the updated state

  • The store notifies all the connected components of the state change.

  • Each UI component will verify if it needs to use the updated state.

  • If it does, it re-renders the UI with the new state and updates what is on the screen.

ReduxArchi

Creating a store, subscribing and dispatching actions

In this section, we will learn how to create a store and dispatch actions to the store.

The Redux store brings together the state, reducer and action of our application. It is the central location of the application's state.

The functions of the store are to:

  • Hold the current state of the application.

  • Allow access to the current state

  • Allow the state to be updated

  • Dispatch actions

  • Subscribe to changes

Creating a store

Use the createStore() method from the Redux library to create the store. This method accepts a reducer function as an argument.

Below is a code snippet on how to create a store:

//store.js
import { createStore} from 'redux'

const store  = createStore(rootReducer)
Enter fullscreen mode Exit fullscreen mode

Next, you will need to pass the "root reducer" function to the createStore(). The root reducer combines all of the other reducers in your application.

To create a root reducer, you import the combineReducer() method from the Redux library. The combineReducer helps you combine all the different reducer functions. It accepts an object of reducer functions as its argument. The key of the object will become the keys in your root state object, and the values are the reducer functions.

Below is an example of how to create a rootReducer :

import { combineReducers } from 'redux';

const reducers = {
  user: userReducer,
  cart: cartReducer,
  orders: ordersReducer,
};

const rootReducer = combineReducers(reducers);
Enter fullscreen mode Exit fullscreen mode

Now, you have learned how to

  • Create a store

  • Add a root reducer to the store

Next, you will learn how to get the initial state of the store and dispatch actions to the store

Dispatching actions to the store

To update the state of the application, the component needs to dispatch actions.

Below is how to do that:

  • Import the store into your application

  • call the store.dispatch() methods and pass it the action objects.

//actions object
const addTodo = {
  type: 'todos/todoAdded',
  payload: "Buy milk"
});

const completeTodo = {
  type: 'todos/todoRemoved',
  payload: 2 // id of the todo to complete
}
//dispatch the action to update the state 
store.dispatch(addTodo)
store.dispatch(completeTodo)
Enter fullscreen mode Exit fullscreen mode

Subscribing to the store

Use the subscribe() method to subscribe to a store. The subscribe() method will listen for changes to the state of your app. This will help you update the UI to reflect the current state, perform side effects, or any task that needs to be done when the state changes.

In the code snippet below illustrates how to listen for updates, and log the latest state to the console:

const subscription = store.subscribe(() => {
  // Do something when the state changes.
  console.log('State after dispatch: ', store.getState())
});
Enter fullscreen mode Exit fullscreen mode

Adding Redux to a React app

In this section, you will use the concept learned to build a to-do app with basic functionalities ( add, delete, and complete a todo) while using redux for state management.

We will not go in-depth into each implementation as we have covered the concepts earlier.

Here are the steps to follow:

  1. Set up your React project and install the required dependencies

  2. Create your Todo components for the UI

  3. Create a redux store to track and manage the state of your app

  4. Define the reducer logic and connect to the store

  5. Dispatch actions to update the state

  6. Read data from the store

The repository for the project is found here. It has branches for each major step we will implement.

Let's get started

Step 1: Setting up your project

Create a React app in your project directory by following the steps below:

  • Start a project from the basic template using Vite by running the command below in your terminal:

    npm create vite@latest my-react-app --template react
    // Replace "my-react-app" with the name of your project.
    
  • Install the redux and react-redux dependencies in your package.json file. Run the command

    npm install redux react-redux --save
    

    This installs the core redux architecture and simplifies connecting the react app with redux.

  • Run the app with npm run dev

Step 2: Creating the Todo components

Below is the UI for our app.

TodoRedux

Now, let's create the required components

  • Create a "components" folder in the "src" directory

  • The components needed to create the UI of the app are:

    • <TodoHeading/> : contains the heading text
    • <TodoInput/> : an input to enter a todo
    • <TodoItem/> : displays a single todo, with a delete and complete button
    • <TodoList/> : display the lists of todos.

Components

Step 3: Creating the store

Next, we will need to create a store to keep track of and manage the entire state of the application. We do that using the createStore() method from Redux.

Follow these steps:

  • Create a "store" folder in the root directory of your app

  • Create a store.js file inside the "store" folder

  • Import the createStore method from redux

  • Call the createStore() method, and pass the todoReducer as an argument ( we will define this in the next step)

  • Export the store to be used inside your React app

//store/store.js
import { createStore } from "redux";
import todoReducer from "../reducer/todoReducer";

const store = createStore(todoReducer);

export default store;
Enter fullscreen mode Exit fullscreen mode

Step 4: Defining the reducer function

We will define our reducer inside a todoReducer.js file. Reducers are functions that contain the logic required to update the state and return a new state. The todoReducer.js will also contain the initial state of our app.

Follow the steps below to define a reducer function:

  • Create a todoReducer.js insider a "reducer" folder

Add the code snippet below to the file

//state object
const initialState = {
  todos: [
    {
      id: 1,
      item: "Learn redux fundamentals",
      completed: false,
    },
    {
      id: 2,
      item: "Build a todo-app",
      completed: false,
    },
  ],
};

//define the reducer logic
const todoReducer = (state = initialState, action) => {
  switch (action.type) {
//logic to add a new todo
    case "todos/addedTodo":
      return {
        ...state,
        todos: [...state.todos, action.payload],
      };
//logic to delete a todo
    case "todos/deleteTodo":
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload),
      };
// logic to complete a todo
    case "todos/completeTodo":
      return {
        ...state,
        todos: state.todos.map((todo) => {
          if (todo.id === action.payload) {
            return {
              ...todo,
              completed: !todo.completed,
            };
          } else {
            return todo;
          }
        }),
      };
    default:
      return state;
  }
};

export default todoReducer;
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We added the initial state of our app. It is prefilled with an array of todos to enable us to display some dummy data when the app is rendered.

  • We define the todoReducer function. This function accepts the state and action objects as parameters.

  • In the body of the function, we implemented a switch statement. Based on the expression (action.type) there is a logic in each case on how the state will be updated and returned.

  • Finallly, we export the todoReducer and pass it as an argument to the createStore() method (as previously indicated).

Step 5: Wrapping the Provider component around your app

The Provider component enables the Redux store to be available to any nested components that need to access the store.

Since any React component in a React Redux app can be connected to the store, most applications will render a Provider at the top level, with the entire app’s component tree inside of it.

Below is how to wrap our root component inside a Provider

  • Import the store in the main.jsx

  • Import the Provider component from "react-redux"

  • Wrap your root component <App/> in the Provider

  • The Provider accepts a store props with the imported store as its value

//main,jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
import { Provider } from "react-redux"; //Provider
import store from "./store/store.js"; //store 

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <Provider store={store}> 
      <App />
    </Provider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

In the code above, we have wrapped the <Provider/> around <App/> component to enable all nested components to access the store

Next, we would read and display data from the store using the useSelector hook

Step 6: Reading and displaying the todos with useSelector hook

React-Redux has its custom hooks, which you can use in your components. The useSelector hook lets the React components read data from the Redux store. It accepts a selector function that takes the entire Redux store state as its argument, reads some value from the state, and returns that result.

Follow the steps below to read and display data:

  • Import the useSelector from "react-redux"

  • Call the useSelector() method. It accepts a selector function as a callback

  • Return the todos from the state

The code below illustrates how to read the todos from our store.

//components/TodoList.jsx
import React from "react";
import { useSelector } from "react-redux";
import TodoItem from "./TodoItem";

const TodoList = () => {
//callback function
  const selectTodos = (state) => state.todos; 
//extract the todos
  const returnedTodos = useSelector(selectTodos);

  const displayTodos = returnedTodos.map((todo) => (
    <TodoItem key={todo.id} todo={todo} />
  ));
  return <div>{displayTodos}</div>;
};

export default TodoList;
Enter fullscreen mode Exit fullscreen mode

Let's understand the code above:

  • The first time the <TodoList/> component renders, the useSelector hook will execute the selectTodos callback function.

  • What is returned by the selectTodos will be returned by the useSelector hook to be used in our component.

  • The const returnedTodos will hold the same data as the state.todos inside our Redux store state.

  • We use the JavaScript map() method to iterate over each todo and display a single todo.

  • The useSelector automatically subscribes to the Redux store. Hence, whenever an action is dispatched, it will call the selectTodosfunction. If the value returned by the selectTodos has been updated, useSelector will force the TodoList component to re-render with the new data.

We know how to read and display data from the store. Next, we will learn how to dispatch actions from the components to update the store

Step 5: Dispatching actions with useDispatch hook

The useDispatch hook provides access to the dispatch method that is needed to dispatch actions to update the state.

We can call const dispatch = useDispatch() in any component that needs to dispatch actions, and then call dispatch(someAction) as needed.

In the TodoInput component, let's dispatch an action to add a new todo:

  • import useDispatch from "react-redux"

  • Call the useDispatch() method. It returns the dispatch function

  • Enter and submit the new todo

  • Call the dipatch() method in the addTodo and pass the action object

    //components/TodoInput.jsx
    import React, { useState } from "react";
    import { useDispatch } from "react-redux";
    
    const TodoInput = () => {
      const [todo, setTodo] = useState("");
      const dispatch = useDispatch();
    
      const onInputTodo = (e) => {
        setTodo(e.target.value);
      };
      //handle submission of todo
      const handleTodoSubmit = (e) => {
        e.preventDefault();
        addTodo(); // addTodo 
        setTodo("");
      };
    //action creators
      const addTodo = () => {
    //dispatch action to add a todo
        return dispatch({
          type: "todos/addedTodo",
          payload: { id: Math.floor(Math.random() * 20) + 1.1, item: todo },
        });
      };
      return (
        <div>
          <form className="todo_form_container" onSubmit={handleTodoSubmit}>
            <input
              className="todo_input"
              type="text"
              placeholder="Enter your todo"
              value={todo}
              onChange={onInputTodo}
            />
            <button className="todo_btn">Add Todo</button>
          </form>
        </div>
      );
    };
    
    export default TodoInput;
    

In the code above, on submitting a new todo:

  • The handleTodoSubmit function executes the addTodo() function

  • The addTodo() dispatches the action object to the todoReducer function to update the state

Because we have imported the useSelector hook, we can easily add a new todo to the store's state, and it will reflect in the UI.

Below is what we have done so far

  • Create the store

  • Wrap the <Provider store={store}> around your top-level <App> component to enable all other components to access and update the store.

  • Call the useSelector hook to read data in React components

  • Call the useDispatch hook to dispatch actions in React components

Dispatching action on clicking the "delete" and "complete" buttons

In the TodoItem components, we can now click on the "delete" and "complete" button. On clicking these buttons we dispatch actions to delete and complete a todo. These are handled in the onDelete and onComplete action creators.

The code snippet is as below:

//components/TodoItem.jsx
import { useDispatch } from "react-redux";

const TodoItem = ({ todo }) => {
  const dispatch = useDispatch();
  //delete a todo
  const onDelete = (id) => {
    return dispatch({
      type: "todos/deleteTodo",
      payload: id,
    });
  };
  //complete Todo
  const onComplete = (id) => {
    return dispatch({
      type: "todos/completeTodo",
      payload: id,
    });
  };
  return (
    <div>
      <h3 className={`todo${todo.completed ? "Completed" : ""}`}>
        {todo.item}
      </h3>
      <div>
        <button onClick={() => onComplete(todo.id)}>Complete</button>
        <button onClick={() => onDelete(todo.id)}>Delete</button>
      </div>
    </div>
  );
};

export default TodoItem;
Enter fullscreen mode Exit fullscreen mode
  • Clicking the "delete" and "complete" button calls the onDelete() and onComplete functions respectively. The onDelete function dispatches an action to the todoReducer to delete the specified item while the onComplete function dispatches an action to the todoReducer to complete the selected item.

Redux vs Context API

The difference between Redux and Context API is how they manage states. In Redux state is managed in a central store. However, the Context API deals with state updates on a component level, as they happen within each component.

You might ask, can't you use the useContext hook from the Context API to pass state to multiple components since that eliminates prop drilling?

In a scenario where a state affects multiple components or is required by all the components in an app, you can use the useContext hook to manage state. This avoids props drilling and makes data easily accessible in all components.

However, there are some disadvantages to using useContext:

  • The useContext hook has a complex setup: When building an enterprise-level app where multiple components may need access to state, you might have to use the context API to create multiple contexts and provide each context with the data required for the different aspects of your application. For instance, you will create

    • Authentication Context: to easily authenticate users
    • Theming Context: to change the theme. For example, enable dark mode
    • Form Context: to pass form data to the form component, etc.

    This phenomenon might result in having to create multiple contexts to meet a specific need, leading to deeply nested Context Provider components in your application.

context

  • Secondly, useContext is not optimized for enterprise-level apps where the state changes frequently. This will decrease the performance of your app.

  • Lastly, when using useContext, UI logic and state management will be in the same component.

Below are some scenarios you might use Redux over useContext:

  • There are lots of states in your application, and these states are required in many places in the app.

  • The app state is updated frequently over time

  • The logic to update that state may be complex

  • The app has a medium or large-sized codebase and might be worked on by multiple developers

Conclusion

In this tutorial, you managed the state of a React Todo app using Redux. Next, learn how to manage the state using the Redux Toolkit. Redux Toolkit makes it easier to write good Redux applications and speeds up development. Furthermore, learn Redux DevTools to help you trace when, where, why, and how your application's state changed.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .