Async API calls w/ Redux Toolkit

Async API calls w/ Redux Toolkit

If you've recently moved to Redux Toolkit and struggling to write async thunks, then this blog is for you

Hey there geeks, 👋🏻 today we're going to look into the Redux Toolkit API and learn how to make API calls to fetch dynamic data from 3rd-party API or even your own API.

Prerequisites

  1. A basic understanding of Redux
  2. A React application set up with Redux Toolkit. If you haven't done it yet, read the previous blog in this series on Setting up Redux Toolkit with React JS

Topics covered

  1. How to make async API calls createAsyncThunk
  2. How to handle lifecycle actions
    1. Creating reducers for lifecycle actions
    2. ImmerJS usage patterns
  3. How to handle Thunk errors
    1. Rewriting the thunk
  4. Wrapping up
  5. Reference links

Let's consider we're making a social media app and when the user is on the /feed page we'd like to make an API call to fetch the feed posts and render them on the page. But previously we used to make API calls using the Thunk package. Now, we don't have to install any other package to build async requests.

But now, Redux Toolkit has its own Query Data Fetching API which can eliminate the need to write any thunks or reducers to manage data fetching

How to make API calls using createAsyncThunk

createAsyncThunk is a function that accepts a Redux action type string and a callback function i.e. payloadCreator that should return a promise. It returns a thunk action creator that'll run the promise callback for you and dispatch the lifecycle actions based on the returned promise.

This doesn't create any reducer functions for you, since it doesn't know how you want to handle the data returned by the promise callback which means you'll have to write your own reducers handling the processing logic.

Enough talking let's start with creating an async action for loading posts on the feed page.

// src/features/posts/post.slice.js

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

const loadPosts = createAsyncThunk('posts/loadPosts', async (userId) => {
    try {
        const response = await axios.get('https://jsonplaceholder.typicode.com/posts?_limit=10');
        return response;
    } catch(error) {
        return error;
    }
});

The above code will fetch posts for the user and return the response. Since we're using Axios here the response will be constructed as

// Axios response

{
    ...otherFieldsAddedByAxios,
    data: {
        posts: [...],
    },
}

One thing to know here is that whenever you return data from the payloadCreator is added to the payload field. Hence, when we'll access the promise returned data in the reducers it'll be nested like action.payload.data.posts

Where did the action come from? Every reducer takes 2 parameters state and action and Redux attaches the payload to the action along with some meta-information. So when the response is attached to the reducer it'll look something like this

// Response in reducer

{
    ...meta,
    payload: {
        // Axios response

        ...otherFieldsAddedByAxios,
        data: {
            posts: [...],
        },
    }
}

Also it's not advisable to put the entire Axios response in the action as it might contain unserializable data. You can handpick the fields you need in the action and just pass those

Now let's see how to handle the lifecycle actions in reducers. 🥳

How to handle lifecycle actions

To handle these actions in your reducers, reference the action creators in createReducer or createSlice using either the object key notation or the builder callback notation. (Note that if you use TypeScript, you should use the "builder callback" notation to ensure the types are inferred correctly):

createAsyncThunk will generate 3 Redux actions pending, fulfilled & rejected that'll be attached to the returned thunk. So in our case the thunk action is named posts/loadPosts and it'll have 3 more actions named posts/loadPosts/pending,posts/loadPosts/fulfilled & posts/loadPosts/rejected

Creating reducers for lifecycle actions

The lifecycle reducers can't be passed to the reducers in createSlice(). They've to be passed in the extraReducers

// src/features/posts/post.slice.js

const postSlice = createSlice({
    name: 'posts',
    initialState: {
        status: 'idle',
        error: null,
        posts: [],
    },
    reducers:{},
    extraReducers: {
        // You can show loading state in the UI
        [loadPosts.pending]: (state)=> {
            state.status = 'loading';
            state.error = null;
        }, 
        // Render the API response
        [loadPosts.fulfilled]: (state, action)=> {
            state.status = 'fulfilled';
            state.error = null;
            state.posts = action.payload.data.posts;
        },
        // Display error state
        [loadPosts.error]: (state, action)=> {
            state.status = 'error';
            state.error = action.payload.error;
        },  
    },
});

We're using the object notation here for the sake of the tutorial. You must really use the builder notation which is more flexible and works with TypeScript too, as mentioned by the Co-Maintainer of Redux Toolkit

Here's how the builder notation would look like

extraReducers: (builder) => {
    builder.addCase(loadPosts.fulfilled, (state, action) => {})
},

In all the reducers we're updating the state.status based on which we can update the UI and keep track of the request. Notice how we're not using Immutable State Updates, that's because Redux Toolkit's createReducer uses ImmerJS internally to handle the state updates.

createSlice uses createReducer internally so it's safe to mutate the state there too as we did above. Refer to this document to know more on Writing reducers with ImmerJS

While ImmerJS handles the state updates for us even if we write mutable code, you need to know that the state is still updated immutably by ImmerJS under the hood. If you return a mutable code from the reducer it won't work

ImmerJS usage patterns

// src/features/posts/post.slice.js

const postSlice = createSlice({
    ...,
    extraReducers: {
        // This would be handle by ImmerJS
        [loadPosts.fulfilled]: (state, action)=> {
            state.status = 'fulfilled';
            state.error = null;
            state.posts = action.payload.data.posts;
        },
        // This would work as an immutable update
        [loadPosts.fulfilled]: (state, action)=> {
            return {
                ...state,
                status: 'fulfilled',
                error: null,
                posts: action.payload.data.posts,
            }
        },
        // This won't work
        [loadPosts.fulfilled]: (state, action)=> {
            state.status = 'fulfilled';
            state.error = null;
            state.posts = action.payload.data.posts;

            return state;
        },
    },
});

Read more usage patterns here. Also, read about the Caveats of ImmerJS

How to handle thunk errors

When your request returns a rejected promise ( such as a thrown error in an async function ), the thunk will dispatch the rejected action containing an automatically serialized version of the error as action.error. However, to ensure serializability, everything that does not match the SerializedError interface will be removed from it:

export interface SerializedError {
  name?: string
  message?: string
  stack?: string
  code?: string
}

If you would like to customize the contents of the rejected action, you must catch any errors yourself and return a new value using the rejectWithValue utility. Returning the error like the below code will cause the rejected action to use that value as action.payload in the rejected action.

return rejectWithValue({
    ...someMoreCustomErrorFields,
    message: 'Unable to fetch posts',
});

Rewriting the thunk

Remember when we created the loadPosts thunk, well we did one thing incorrectly. Returning the error as we did in the catch will trigger the fulfilled action with the error passed in action.payload this will break the app because we're not looking for the error fields in the fulfilled action. To fix it we'll be using the rejectWithValue() utility.

The createAsyncThunk's callback function i.e. payloadCreator takes 2 parameters an arg( a single value passed to the thunk action creator while dispatching. This is useful to pass values like post ids that may be needed as a part of the request ) and thunkAPI ( An object containing all of the parameters that are normally passed to a Redux thunk action and some extra options ). Hence, we'll be restructuring the rejectWithValue from thunkAPI and use it to return a customized error to use in the rejected action

// src/features/posts/post.slice.js

const loadPosts = createAsyncThunk('posts/loadPosts', async (userId, { rejectWithValue }) => {
    try {
        const response = await axios.get('https://examplesite.com/posts?user=userId');
        return response;
    } catch(error) {
        // Since Axios appends the error data in error.response.data
        return rejectWithValue(error.response.data);
    }
});

Now we can access the customized error in the rejected action as action.payload

[loadPosts.rejected]: (state, action)=> {
    state.status = 'error';
    state.posts = [];
    state.error = action.payload.message; 
},

Wrapping up

Also, don't forget to check out RTK-Query created by Lenz Weber. RTK-Query now ships with Redux Toolkit and automates all the things we did today.

If you want to learn more on all the topics covered, go through the reference links below

Reference links

  1. createAsyncThunk
  2. Promise lifecycle actions
  3. Handling thunk results
  4. Writing reducers with ImmerJS
  5. ImmerJS caveats
  6. Immer usage patterns
  7. Handling thunk errors