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
- A basic understanding of Redux
- 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
- How to make async API calls createAsyncThunk
- How to handle lifecycle actions
- How to handle Thunk errors
- Wrapping up
- 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 thebuilder 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