How to Use Async Thunk in React with Redux Toolkit

The first thing I want my readers to understand is that React Tool Kit's Query data fetching API has essentially replaced the Async Thunk. However, it's still important to learn this concept before moving forward. I will also discuss RTK and how to implement it in your projects. We will learn how to create slices, write reducers, set up stores, and use useDispatch with an example. This is a concise guide meant to cover only the basics. Let's understand these concepts by fetching data from the Pokémon API and displaying them.

What is Redux Toolkit?

Redux Toolkit simplifies the use of Redux. It includes many built-in tools that help with centralizing state management. The immer library it uses under the hood also helps with updating the state more efficiently.

Installing Redux Toolkit and react-redux

Inside your React project, use these two commands to install Redux Toolkit and react-redux. We will use react-redux for dispatch and store later.

npm install @reduxjs/toolkit
npm install react-redux

Creating Slices for Redux Toolkit

Slices are sections of your state management where you define the state for specific features. For example, in a large social media app, you might be fetching users, posts, comments, and reactions. Managing all of this in one file would make it hard to read. That's why we create different slices for each feature. Let's create a pokemonSlice to understand it better.

Inside your src folder, create a folder called store, and inside it, create another folder called slices. Next, create a file named pokemonSlice.js. inside the slices folder. We will manage all the states related to Pokémon here.

// src/store/slices/pokemonSlice.js
import { createSlice } from "@reduxjs/toolkit";

const pokemonSlice = createSlice({
  name: "pokemon",
  initialState: {
    data: [],
  },
  reducers: {},
  // extraReducers function
});

export const pokemonReducer = pokemonSlice.reducer; // For store

To create slices, we need to use the createSlice function. This function takes an object with three important properties: name, initialState, and reducers. The name distinguishes it from other slices, and initialState initializes the state. Remember, this state is part of the larger state we will define in the store; it is just a slice. reducers are small functions we use to modify this specific state. However, we will not use them in this project. We will mainly work with extraReducers because we are using thunk.

So, what is the difference between reducers and extraReducers? reducers handle the action types created here. For example, if you create addPokemon inside reducers, it will automatically define your action types. This action creator, combined with dispatch, will be used to change the state. I won't go in-depth on this because our primary goal is to use thunk.

The extraReducer can be used to handle action types from other slices or custom action types from something like thunk. Unlike reducers, it does not create action creator functions.

Defining the Redux store and setting it up

Here we configure the store by defining the reducer. The property we provide here is essentially the state name that we will use to access it with the useSelector hook. All the states from various slices, like pokemon, will be accessible in the large state object that we use in useSelector. For example, we can use state.pokemon.data from the data we defined above in initialState to utilize it. This will become clearer in the later sections.

Configure the store inside the src/store/index.js.

// src/store/index.js
import { configureStore } from "@reduxjs/toolkit";
import { pokemonReducer } from "./slices/pokemonSlice";

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

Next, we use the react-redux package's Provider to make the store accessible to all components in our React application. Depending on whether you use Vite or Create React App, open index.jsx or main.jsx inside the src folder to configure the Provider. Pass the store as a prop to the Provider component from react-redux. At this point, you should try running the application to see if everything works or if you encounter any errors.

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

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

If you get an error about extraReducer from pokemonSlice, just comment it out for now. We will set it up in later sections.

Creating an async thunk function

Inside the src/store/thunk folder, create a file called fetchPokemon.js. We will use this file to fetch our Pokémon data.

At this point, you can choose to use either axios or the fetch API to make your API calls. In this case, I am going to use axios. If you want to follow along, I suggest you install it by running npm i axios in your project's root folder.

import { createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";

const fetchPokemon = createAsyncThunk("pokemon/fetch", async () => {
  const response = await axios.get("https://pokeapi.co/api/v2/pokemon");
  return response.data;
});

export { fetchPokemon };

Now, why do we use thunks? The reason is that we cannot call asynchronous functions inside reducers. So, we use async thunks or something like RTK Query.

To create a thunk function, we use createAsyncThunk from redux-toolkit. It takes two important arguments. The first one defines the action creator type, which is just a name for how this thunk should behave. The second argument is an asynchronous function where we return data. Similar to Promises, a thunk has three states: pending, fulfilled, and rejected.

  • pending: When the thunk is pending, it means the request is still in progress. It sets a pending method with the action type that differentiates it. In this instance, it is fetchPokemon.pending. We can use it in our extraReducer to display something like a loader.

  • fulfilled: When the thunk is fulfilled, whatever data is returned from response.data is set inside the action.payload.

  • rejected: In case of rejection, which means something went wrong, an error property is set in the action.error with the error.

Utilizing the Action Creator within extraReducers in Slices

// src/store/slices/pokemonSlice.js
import { createSlice } from "@reduxjs/toolkit";
import { fetchPokemon } from "../thunk/fetchPokemon";

const pokemonSlice = createSlice({
  name: "pokemon",
  initialState: {
    isLoading: false,
    error: null,
    data: [],
  },
  extraReducers(builder) {
    builder.addCase(fetchPokemon.pending, (state, action) => {
      state.isLoading = true;
    });
    builder.addCase(fetchPokemon.fulfilled, (state, action) => {
      state.isLoading = false;
      state.data = action.payload.results;
    });
    builder.addCase(fetchPokemon.rejected, (state, action) => {
      state.isLoading = false;
      state.error = action.error;
    });
  },
});

export const pokemonReducer = pokemonSlice.reducer;

The first thing we do is import the fetchPokemon thunk function. As mentioned earlier, it gives us three different action types that we can use for different behaviors. When pending, we set isLoading to true. When fulfilled, we hide the loader by setting isLoading to false and update state.data with the data received from fetchPokemon's return statement, which is inside action.payload. We use action.payload.results to access the array of Pokémon returned from the API. Lastly, if the request fails or rejected, we set an error after setting isLoading to false and populate the error inside our state object.

Note that we can modify the state directly here because of the immer library used inside redux-toolkit. The state is an immutable object and should not be modified directly.

Displaying the data in our app

import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { fetchPokemon } from "./store/thunk/fetchPokemon";
import "./App.css";

function App() {
  const { data, isLoading, error } = useSelector((state) => state.pokemon);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(fetchPokemon());
  }, []);
  if (isLoading) return <div>Fetching data...</div>;
  else if (error) return <div>Something went wrong...</div>;
  else
    return (
      <div>
        {data.map((pokemon) => (
          <p key={pokemon.name}>{pokemon.name}</p>
        ))}
      </div>
    );
}

export default App;

Here, we use the useSelector hook to access our state and display the data. The state object represents the overall state of our app. To access the Pokémon part of it, which we defined in our slices, we refer to the property we specified inside our configureStore object. This is why we use state.pokemon. The data, isLoading, and error are defined inside the pokemonSlice. We use the useDispatch hook to call our fetchPokemon thunk function inside the useEffect hook.

To display the data, we add conditions for different states of our data and display our Pokémon.

Thank you for reading this, and I hope you enjoyed it!

11
Subscribe to my newsletter

Read articles from Shiwanshu Shubham directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shiwanshu Shubham
Shiwanshu Shubham

Hey there, I'm Shiwanshu, a passionate frontend developer and designer hailing from India. With proficiency in NextJS, ReactJS, CSS3, HTML5, Figma, UI Design, and Typescript, I've embarked on a journey into the tech space. This blog is a documentation of my progress as I delve deeper into the world of technology.