DEV Community

Cover image for Mastering Async Logic in Redux Toolkit with createAsyncThunk
Chinedu Oputa
Chinedu Oputa

Posted on • Edited on

Mastering Async Logic in Redux Toolkit with createAsyncThunk

Introduction

Modern JavaScript applications need to handle asynchronous logic — fetching data, posting forms, authentication, file uploads, etc. In older Redux projects, async operations required complex setups using middlewares like redux-thunk, action creators, constants, and reducers. This made Redux feel boilerplate-heavy and repetitive.
Redux Toolkit (RTK) changed everything. It streamlined Redux development and made async operations simpler, readable, and scalable with the createAsyncThunk() API. But what is createAsyncThunk()? Let’s answer that question in the next section.

What is create createAsyncThunk()

createAsyncThunk is a utility from Redux Toolkit designed to simplify writing async logic like API calls. It automatically:
Manages pending, fulfilled, and rejected actions
Handles errors for you
Supports async/await cleanly
Let reducers respond to async life cycles easily
Think of it as a smart wrapper for asynchronous requests in Redux.

Basic structure of createAsyncThunk

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

const postUrl = "https://jsonplaceholder.typicode.com/posts";

export const fetchPosts = createAsyncThunk("posts/fetchPosts", async () => {
 const response = await fetch(postUrl);
 if (!response.ok) throw Error("An error occoured! Unable to fetch posts.");
 const data = await response.json();
 return data;
});
Enter fullscreen mode Exit fullscreen mode

From the code above, we see that createAsyncThunk takes two arguments. The first argument is used as a prefix for the automatically generated action types, and the code above automatically generates three Redux actions:
posts/fetchPosts/pending, triggered when the request starts
posts/fetchPosts/fulfilled, triggered when the request is successful
posts/fetchPosts/rejected, triggered when the request fails
The second argument is a payload creator callback and createAsyncThunk function above either returns a promise that contains some data or a rejected promise with an error. And as we see from the payload creator callback, we are fetching data from the jsonplaceholder website.
In Redux Toolkit, action creators are automatically generated based on the specified reducer in the createSlice function. However, sometimes, a slice reducer needs to respond to other actions that are not defined as part of the slice reducer. And this is the case for actions generated from createAsyncThunk.
Actions generated from createAsyncThunk are handled by extraReducers, which uses the builder callback notation as seen below:

  extraReducers: (builder) => {
   builder
     .addCase(fetchPosts.pending, (state, action) => {
       state.status = "loading";
     })
     .addCase(fetchPosts.fulfilled, (state, action) => {
       state.status = "success";
       let min = 1;
       const loadedPosts = action.payload?.map((post) => {
         post.date = sub(new Date(), { minutes: min++ }).toISOString();
         return post;
       });
       state.posts = state.posts.concat(loadedPosts);
     })
     .addCase(fetchPosts.rejected, (state, action) => {
       state.status = "failed";
       state.error = action.error?.message;
     })
 },
});
Enter fullscreen mode Exit fullscreen mode

In Redux Toolkit (RTK), the builder callback notation is a pattern used inside createSlice() to define reducers for actions created by createAsyncThunk() or other external actions. Instead of defining reducers as an object, you use a function that receives a builder object parameter—allowing you to chain method calls to handle different action types more flexibly, as seen in the code above.
The builder parameter of the extraReducers function is an object that lets you define additional case reducers that run in response to the actions defined outside of the slice, as seen in the code above.
In this article, we will learn how to handle asynchronous logic in Redux Toolkit using createAsyncThunk by building a simple blog. But before we delve into this, the prerequisites to gain the most from this article are given below.

Prerequistes

  1. NodeJS should be installed on your system
  2. Knowledge of React
  3. Knowledge of JavaScript
  4. Knowledge of Redux Toolkit ## Building A Blog With React And Redux Toolkit Redux does everything synchronously, so anything asynchronous has to happen outside the store, and this is where redux middleware comes in. The most common Redux middleware is Redux Thunk. The word thunk is a programming term that means a piece of code that does some delayed work. And Redux thunk is recommended as the standard approach for writing async logic with Redux.

Redux Toolkit uses Redux thunk under the hood to handle asynchronous actions, and it is included by default in Redux Toolkit's configureStore.

In this section, we will learn how Redux handles asynchronous logic by building a blog with React and Redux Toolkit. Let’s get started in the subsection below:

Installation

We will work with Vite in this article. Vite is a super-fast build tool that replaces Create React App (CRA) for modern React development. To use it, in your terminal, run the code below:

npm create vite@latest counter-app
Enter fullscreen mode Exit fullscreen mode

You’ll be prompted with something like this:

? Select a framework: › - Use arrow-keys. Return to submit.
  Vanilla
❯ React
  Vue
  Svelte
  Others
Enter fullscreen mode Exit fullscreen mode

Choose React.
Now go into the app folder by running:

cd counter-app
Enter fullscreen mode Exit fullscreen mode

Then run the app with:

npm run dev
Enter fullscreen mode Exit fullscreen mode

After running the code above, to view the app, visit http://localhost:5173/, and you will get:

React and Vite logo

Next, install Redux Toolkit and the react-redux and date-fns package by running:

npm install @reduxjs/toolkit react-redux date-fns
Enter fullscreen mode Exit fullscreen mode

Now we can set up Redux Toolkit inside the app. To do this, first, we configure the Redux store by following the steps in the next section.

Configure the Store

To configure the Redux store, create an app directory inside the src directory. Then create a file named store.js inside the app directory. Now add the code below to the store.js file:

// app/store.js

import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";

export const store = configureStore({
  reducer: { },
});
Enter fullscreen mode Exit fullscreen mode

After configuring the store, we need to provide the global state to our application.

Provide the Store to React

To provide the store to React, replace the code in the main.jsx with the following code:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import { store } from "./app/store.js";
import { Provider } from "react-redux";

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

In the code above, we used the Provider component to provide our store — our global state, to our application. Next, we create a slice.

Create Slice

To create the postSlice, create a folder called features inside the src directory, and inside the features directory, create a folder named posts. Inside the posts directory, create a file named postSlice.js and add the code below to the postSlice.js:

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { sub } from "date-fns";

const postUrl = "https://jsonplaceholder.typicode.com/posts";

export const fetchPosts = createAsyncThunk("posts/fetchPosts", async () => {
 const response = await fetch(postUrl);
 if (!response.ok) throw Error("An error occoured! Unable to fetch posts.");
 const data = await response.json();
 return data;
});

export const addPost = createAsyncThunk("post/addPost", async (postBody) => {
 const response = await fetch(postUrl, {
   method: "POST",
   headers: {
     "Content-Type": "application/json",
   },
   body: JSON.stringify(postBody),
 });
 const data = await response.json();

 return data;
});


const initialState = {
 posts: [],
 status: "idle", // pending | success | failed
 error: null,
};

export const postSlice = createSlice({
 name: "posts",
 initialState,
 reducers: {},
 extraReducers: (builder) => {
   builder
     .addCase(fetchPosts.pending, (state, action) => {
       state.status = "loading";
     })
     .addCase(fetchPosts.fulfilled, (state, action) => {
       state.status = "success";
       let min = 1;
       const loadedPosts = action.payload?.map((post) => {
         post.date = sub(new Date(), { minutes: min++ }).toISOString();
         return post;
       });
       state.posts = state.posts.concat(loadedPosts);
     })
     .addCase(fetchPosts.rejected, (state, action) => {
       state.status = "failed";
       state.error = action.error?.message;
     })
     .addCase(addPost.fulfilled, (state, action) => {
       state.status = "success";
       action.payload.date = new Date().toISOString();
       state.posts.push(action.payload);
     })
 },
});

// get the initial state object in this slice
// this function is passed to the useSelector hook to get data from the initial state in this slice in a component.
export const selectPostSliceState = (state) => state.posts;

export const {} = postSlice.actions;

export default postSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

In the code above, the initialState is an object with 3 properties namely posts — which is an empty array because we have not hydrated it yet, status — which has a value of idle but the value can also be loading, succeeded or failed, and error — which have a value of null.

The selectPostSliceState function gives access to the initialState when it is used with the useSelector hook from a component, as seen below:
const { posts, status, error } = useSelector(selectPostSliceState)
When the code above is used inside a component, it destructures the posts, status, and error from the initialState.

The benefit of using this pattern instead of accessing the initialState from our components like this:

const { posts, status, error } = useSelector( (state) => state.posts)
Enter fullscreen mode Exit fullscreen mode

is that if the shape of the initialState changes, we do not need to change the code in all the components where it is used; we only need to change the selectPostSliceState in the slice.

Next, we need to add the created reducer to the store. To do this, update the store.js file as seen in the code below:

import { configureStore } from "@reduxjs/toolkit";
import postReducer from "../features/posts/postSlice";

export const store = configureStore({
 reducer: {
   posts: postReducer,
 },
});
Enter fullscreen mode Exit fullscreen mode

Create Post List

Now, we create the postList component. To do this, in the posts directory inside the features directory, create a file named postList.jsx and add the code below to it:

import { useSelector } from "react-redux";
import { selectPostSliceState } from "./postSlice";
import PostItem from "./PostItem";

const PostList = () => {
 const { posts, status, error } = useSelector(selectPostSliceState);

 const sortedPosts = posts
   .slice()
   .sort((a, b) => b.date.localeCompare(a.date));

 return (
   <div>
     {status === "loading" && <h1>Loading...</h1>}
     {status === "failed" && <p>{error}</p>}
     {status === "success" &&
       sortedPosts.map((post) => <PostItem post={post} key={post.id} />)}
   </div>
 );
};

export default PostList;
Enter fullscreen mode Exit fullscreen mode

The code above displays loading if status is loading, it displays the error if status is failed, and maps through the sortedPosts array passing each post to the PostItem component if status is success.
Next, let’s create the postItem component. To do this, create a file named postItem.jsx inside the posts directory that is in the features directory. Then add the code below to the postItem.jsx component:

import TimeAgo from "./TimeAgo";

const PostItem = ({ post }) => {

 return (
   <article>
     <div>
         <h2>{post.title}</h2>
         <TimeAgo timestamp={post.date} />
     </div>
   </article>
 );
};

export default PostItem;
Enter fullscreen mode Exit fullscreen mode

Now, let's create the TimeAgo component. In the posts directory that is inside the features directory, create a component named TimeAgo.jsx and the following code to this component:

import { parseISO, formatDistanceToNow } from "date-fns";

const TimeAgo = ({ timestamp }) => {
 let timeAgo = "";
 if (timestamp) {
   const date = parseISO(timestamp);
   const timePeriod = formatDistanceToNow(date);
   timeAgo = `${timePeriod} ago`;
 }

 return (
   <span title={timestamp}>
     &nbsp; <i>{timeAgo}</i>
   </span>
 );
};

export default TimeAgo;
Enter fullscreen mode Exit fullscreen mode

The code above displays the time in human-readable format using the date-fns package.

Create The AddPost Form

To do this, in the posts directory, inside the features directory, create a file named AddPost.jsx and add the following code to it:

import { useState } from "react";
import { useDispatch } from "react-redux";
import { addPost } from "./postSlice";

const AddPost = () => {
 const [title, setTitle] = useState("");
 const [body, setBody] = useState("");
 const dispatch = useDispatch();

 const onSavePostClicked = () => {
   try {
     const newPost = { title, body };
     dispatch(addPost(newPost)).unwrap();
   } catch (error) {
     console.log("error", error.message);
   }

   setTitle("");
   setBody("");
 };

 return (
   <section>
     <h2>Add a New Post</h2>
     <form>
       <label htmlFor="postTitle">Post Title:</label>
       <input
         id="postTitle"
         name="postTitle"
         onChange={(e) => setTitle(e.target.value)}
         value={title}
         type="text"
       />
       <label htmlFor="postContent">Content:</label>
       <textarea
         id="postContent"
         name="postContent"
         value={body}
         onChange={(e) => setBody(e.target.value)}
       />
       <button type="button" onClick={onSavePostClicked}>
         Submit
       </button>
     </form>
   </section>
 );
};

export default AddPost;
Enter fullscreen mode Exit fullscreen mode

From the code above, we see that the AddPost component is a form with two controlled components, namely input and textarea.
The onSavePostClicked dispatch a thunk from the AddPost component (using dispatch(addPost(newPost)).unwrap()). This returns a promise — but by default, that promise does not reject on error. Instead, it always resolves to an “action object” describing what happened.
And that’s where .unwrap() comes in. The unwrap() method converts that action result back into a “normal” promise that behaves like a regular async/await call.

Now update the App.jsx file by replacing the code with the following:

import PostList from "./features/posts/PostList";
import AddPostForm from "./features/posts/AddPost";

function App() {
 return (
   <main className="App">
     <AddPostForm />
     <PostList />
   </main>
 );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Finally, replace the code in the index.css file with the following:

* {
 margin: 0;
 padding: 0;
 box-sizing: border-box;
}

html {
 font-family: Cambria, Cochin, Georgia, Times, "Times New Roman", serif;
 background: #333;
 color: whitesmoke;
}

body {
 min-height: 100vh;
 font-size: 1.5rem;
 padding: 0 10% 10%;
}

input,
textarea,
button,
select {
 font: inherit;
 margin-bottom: 1em;
}

main {
 max-width: 500px;
 margin: auto;
}

section {
 margin-top: 1em;
}

article {
 margin: 0.5em 0.5em 0.5em 0;
 border: 1px solid whitesmoke;
 border-radius: 10px;
 padding: 1em;
}

h1 {
 font-size: 3.5rem;
}

p {
 font-family: Arial, Helvetica, sans-serif;
 line-height: 1.4;
 font-size: 1.2rem;
 margin: 0.5em 0;
}

form {
 margin-top: 10px;
 display: flex;
 flex-direction: column;
}

.postCredit {
 font-size: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

Now, when we view our app, we get:

Add post form without post list

When we view the app, we only see the add post form. The posts from jsonplaceholder are not visible. This is not the kind of behaviour we want from our app. We want to load all posts once our app loads and display them. To achieve this, we call fetchPosts by using the code below:

 store.dispatch(fetchPosts());
Enter fullscreen mode Exit fullscreen mode

Now, update the code in the main.jsx file with the following:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import { store } from "./app/store.js";
import { Provider } from "react-redux";
import { fetchPosts } from "./features/posts/postSlice.js";

 // call fetchPosts
store.dispatch(fetchPosts());

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

Now, we get:

Add post form and post list

Conclusion

createAsyncThunk is one of the most powerful features of Redux Toolkit. It:
Removes async boilerplate
Simplifies API calls
Improves error handling
Works perfectly with modern React apps
Produces clean, scalable state management code

If you used to hate Redux for being "too heavy", Redux Toolkit — especially createAsyncThunk — will change your mind.
Lastly, I do hope that you have learned something from this article. Kindly leave your thoughts in the comment section below.

💼 Hire Me

I'm currently available for freelance or full-time frontend or backend development work.

What I do:

  • React, Redux Toolkit, NodeJS, ExpressJS, and TailwindCSS
  • Building scalable web applications
  • Creating responsive, user-friendly UIs

📧 Email: Leave a messsage

🌐 Portfolio: My portfolio
💬 LinkedIn: linkedin.com/in/yourprofile

If you like my work, let’s collaborate!

Top comments (0)