Understanding Redux: A Complete Guide to State Management

Harshit Bansal's photo
·

8 min read

Understanding Redux: A Complete Guide to State Management

What is Redux ?

Redux is a state management library for JavaScript applications, widely used with React. It helps manage and centralize the application’s state, making state updates predictable and easier to debug, especially in large or complex applications.

Note: Another popular library for state management is Zustand.

Why Use Redux?

  1. Centralized State Management: Redux provides a single source of truth (a global store) for managing application state.

  2. Predictable State Updates: State changes are handled through pure functions called reducers.

  3. Debugging: With tools like Redux DevTools, you can trace changes in the state and easily debug your application.

  4. Scalability: Ideal for applications with shared data across multiple components.

Redux provides two different packages to work with React apps that are React-Redux and Redux-Toolkit.

What is React Redux ?

React Redux is the official React binding for Redux. It provides a set of tools and utilities to connect your React components to the Redux store, allowing them to access state and dispatch actions.

Key Features:

  1. Provider: Wraps the React app and passes the Redux store down to components.

  2. useSelector: Retrieves state from the Redux store.

  3. useDispatch: Dispatches actions to the store.

  4. connect (HOC): An older method to connect React components to Redux.

What is Redux Toolkit ?

Redux Toolkit (RTK) is the official, modernized way to write Redux logic. It reduces boilerplate, simplifies common patterns, and includes additional utilities to manage state more effectively. Redux Toolkit has now replaced the older method of managing states through redux known as Vanilla Redux.

Key Features:

  1. Simplifies store setup with configureStore.

  2. Introduces createSlice for writing reducers and actions together.

  3. Provides middleware like redux-thunk out of the box for async actions.

  4. Encourages best practices with a streamlined API.

Difference between Vanilla Redux and Redux Toolkit

Vanilla ReduxRedux Toolkit
Requires manual setupProvides built-in utilities for store and reducers
Actions and reducers are written separatelyCombines them using createSlice
Boilerplate-heavySimplified syntax and structure
Middleware (like redux-thunk) needs separate installationMiddleware comes pre-configured
No built-in async handlingBuilt-in support for async actions using createAsyncThunk

Note: States in Redux are read-only. The only way to change them is to emit an action by writing pure reducers. Reducers take state and actions as arguments and returns a newly updated state.

Core concepts: Store, Actions, Reducers

To understand how Redux works, it is essential to understand its three core concepts: Store, Actions, and Reducers; Let's use a warehouse analogy to explain further.

  1. Store: The store is like a warehouse where all your goods are kept. This warehouse serves as the single source of truth for your inventory. The store is a plain JavaScript object that acts as the single source of truth for your application's state.

  2. Actions: They are like the delivery orders that come into the warehouse. These orders tell the workers what to do—whether it's adding new goods, removing some, or updating the quantity of existing items. Actions are payloads of information that send data from your application to the Redux store. Actions are plain javascript objects that must have a type property that describes the type of action being performed.

    Example of an action:

  3. Reducers: Reducers are like the workers who receive the delivery orders(actions) and make the necessary changes to the inventory. When an order(actions) arrives, the workers(reducers) look at the current state of the warehouse(store) and follow instructions in the delivery order to update the inventory.

    Reducers are pure functions that determine how the state of the application changes in response to an action. They must be pure, meaning they should not mutate the existing state but return a new state object.

Here is a flowchart of a food ordering app in which we will add items in the cart slice.


Starting with Redux

Requirements: To start working with redux, we first need to install the packages required for the working of redux.

npm install @reduxjs/toolkit react-redux

Step 1: Setting up the Store

In your src folder create a new folder called redux and a file in that folder called store.js. Redux Toolkit provides a function called configureStore that simplifies the process of creating a store.

// src/redux/store.js
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import usersReducer from './userSlice';
import productsReducer from './productSlice';

const rootReducer = combineReducers({
  users: usersReducer,
  products: productsReducer,
});

export const store = configureStore({
  reducer: rootReducer,
});

combineReducers helps you organize and manage multiple slices by merging them into one single root reducer, making it easier to maintain and scale your application's state management. Store has its own reducer which makes changes in the store and it is a object which stores the reducers of all the slices in it.

Step 2: Creating a Slice

Slices are logical partitions in the redux store which can be used to store and modify different type of data. A slice is a collection of Redux reducer logic and actions for a specific feature of your application. createSlice function allows you to define the initial state, reducers, and actions all in one place.

The value of the state data in a slice cannot be modified directly. A dispatch method is used to dispatch an action which calls the reducer function and the reducer function modifies the data in the slice.

// src/redux/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
const initialState = {
  users: null,
  loading: false,
  error: null,
};

Next, we create an asynchronous action called getAllUsers using createAsyncthunk. createAsyncthunk generates actions for each stage of an asynchronous operation: pending, fulfilled, and rejected.

Step 3: Creating Actions & Reducers

The getAllUsers action fetches data from the JSON placeholder endpoint. If the request is successful, the response is returned. If it fails, we catch the error and pass it to the rejectWithValue function, which will handle the error state in our slice.

// src/redux/usersSlice.js
export const getAllUsers = createAsyncThunk(
  'users/allUsers',
  async (_, thunkAPI) => {
    try {
      const response = await axios.get('https://jsonplaceholder.typicode.com/users');
      return response;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  }
);

Then we create our users slice using the createSlice function.

// src/redux/usersSlice.js
export const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(getAllUsers.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(getAllUsers.fulfilled, (state, action) => {
        state.loading = false;
        state.users = action.payload;
      })
      .addCase(getAllUsers.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      })
  },
});

This slice doesn't need any reducers in the reducers object because our actions are handled by the extraReducers field. The extraReducers allows us to respond to actions defined outside of the slice. We can add cases for different states of getAllUsers in the extraReducers using the builder API.

So we have the final code as:

// src/redux/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
const initialState = {
  users: null,
  loading: false,
  error: null,
};
export const getAllUsers = createAsyncThunk(
  'users/allUsers',
  async (_, thunkAPI) => {
    try {
      const response = await axios.get('https://jsonplaceholder.typicode.com/users');
      return response.data;
    } catch (error) {
      return thunkAPI.rejectWithValue(error);
    }
  }
);
export const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(getAllUsers.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(getAllUsers.fulfilled, (state, action) => {
        state.loading = false;
        state.users = action.payload;
      })
      .addCase(getAllUsers.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      })
  },
});

export default usersSlice.reducer;

Step 4: Integrating the Store with React

With the store setup and slices defined, the next step is to connect your React components to the Redux store. This is done using the Provider component from react-redux which makes the Redux store available to any nested components that need access to the state.

Wrap your root component with the Provider and pass in the store:

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Step 5: Accessing State and Dispatching Actions in Components

Now that your store is connected, any component within the application can access the Redux store and dispatch actions using React-Redux hooks like useSelector and useDispatch.

// src/pages/Users.js
import React, {useEffect} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { getAllUsers } from './usersSlice';

const Users = () => {
  const {users, loading, error, } = useSelector(state => state.users);
  const dispatch = useDispatch();
  useEffect(()=>{
     dispatch(getAllUsers())
   }, [dispatch])

  return (
   <div>
      <h1>All Users</h1>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      {users?.map((user) => (
        <div key={user.id}>
          <p>{user.name}</p>
          <p>{user.email}</p>
          <p>{user.phone}</p>
        </div>
      ))}
    </div>
  );
};

export default Users;

useSelector allows you to extract data from the Redux store state. This phenomenon is known as Subscribing to the Store which means that now it is in sync with the store and the data in the component will get updated as soon as the data inside redux store changes.

Note: When using the useSelector to subscribe to the store, make sure that you subscribe to the exact path of the store to optimize the app’s efficiency and speed because if you will subscribe to the slices which you don’t need, it can affect the performance of the app negatively.

useDispatch returns a dispatch function from the Redux store which allows you to send actions to the store, in this case, getting all users. If any data is sent as a prop while dispatching the action, it can be accessed in action.payload as redux creates a payload object on dispatching any action and add all the data in the payload object.

In older versions, when vanilla redux was used, mutating the state directly was not allowed by the redux so the developers had to create a copy of the state and then we can make changes in it and return the state back, but modern redux allows directly mutating a state because it internally does the process of creating the copy of the state, updating it and then returning it. It does all of this with the help of Immer library so because of this the developers can mutate the state directly.