State Management in React with Redux Toolkit: A Step-by-Step Guide

Featured on Hashnode

Introduction

State management is crucial for creating dynamic, and scalable user interfaces. As applications become complex, so does the challenge of effectively managing state. Redux has long been the preferred solution for handling state in large React applications, but its complexity and boilerplate code can be challenging for developers.

Redux Toolkit provides a simpler, straightforward approach to state management. Whether you're an experienced developer or a beginner, mastering Redux Toolkit will equip you with the skills to build robust and maintainable applications.

In this step-by-step guide, we'll explore how Redux Toolkit addresses the common pain points of traditional Redux, and we'll build a React application using Redux toolkit to handle state management. By the end of this article, you'll have a solid understanding of how to leverage Redux Toolkit to manage state in your React application.

Understanding Redux

Redux is a state management library that helps you manage the state of your application. It provides a centralized store for your application's state, which allows you to maintain and update state in a structured manner.

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:

     const incrementAction = {
       type: 'INCREMENT',
       payload: 1
     };
    
  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.

    Example of a reducer:

     function counterReducer(state = { count: 0 }, action) {
       switch (action.type) {
         case 'INCREMENT':
           return { ...state, count: state.count + action.payload };
         default:
           return state;
       }
     }
    

Challenges with Traditional Redux

While Redux is powerful, it can be verbose and requires much boilerplate code. You need to create corresponding action types, action creators, and reducers for every action. Additionally, managing asynchronous logic(e.g. API calls) can become complex, requiring middleware like redux-thunk or redux-saga.

These challenges make Redux complex especially for smaller projects or for developers new to the library. This is where Redux Toolkit steps in, providing a more developer-friendly approach to working with Redux.

Introduction to Redux Toolkit

Redux toolkit is the official, recommended way to write Redux logic. It's a library that builds on top of Redux, providing a set of tools and best practices that make it easier to work with Redux. Redux toolkit allows developers to focus on building features rather than managing the intricacies of state management.

Setting Up Redux Toolkit in a React Project

Let's walk through the steps to set up Redux Toolkit, create your store, and define slices to manage your application's state.

Step 1: Installing Redux Toolkit and React-Redux

To do this, run the following command in your project's root directory:

npm install @reduxjs/toolkit react-redux

or, if you're using Yarn

yarn add @reduxjs/toolkit react-redux

Step 2: Setting Up the Redux 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.

Here's how you can set up a basic 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.

Step 3: Creating a Slice

Next, we create a slice for our features. 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.

For demonstration purposes, we'll be using JSON placeholder as a dummy endpoint.

We start by importing createSlice and createAsyncThunk from Redux Toolkit and then define the initialState for 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.

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 rejectWithValie 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 creatSlice 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.

Putting it all together we have:

// 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>
          <p>{user.website}</p>
        </div>
      ))}
    </div>
  );
};

export default Users;

useSelector allows you to extract data from the Redux store state.

useDispatch returns a dispatch function from the Redux store which allows you to send actions to the store, in this case, getting all users.

Conclusion

In this article, we've explored the fundamentals of state management and why Redux Toolkit is a better option for managing state in React applications. We walked through the steps of setting up Redux Toolkit in a React project, creating slices, handling asynchronous actions with createAsyncThunk, and connecting components to the Redux store using React-Redux hooks.

I hope this guide has provided you with a solid foundation for getting started with state management using Redux Toolkit.

For further reading, you can check out the official documentation for Redux Toolkit.