So, this is the third instalment in my Redux-Toolkit series, you can find the first and second instalments below:
-
Part 1: introduction to Redux-Toolkit
-
Part 2: creating slices in Redux-Toolkit
When it comes to managing state in a React application, Redux has become somewhat of an industry standard. Now with the introduction of Redux Toolkit, life has never been easier. It’s functional, easy to set up and you get to create slices of your store for better code maintainability and modularity.
Redux at its core is synchronous, so we need to add middleware like Redux-Thunk or Saga to help us with the asynchronous bit. With Redux-Toolkit, we get Thunk already integrated as a dependency.
createAsyncThunk
According to the official docs: createAsyncThunk is a function that accepts a Redux action type string and a callback function that should return a promise. It generates promise lifecycle action types based on the action type prefix that you pass in, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise.
Let’s break it down:
export const fetchToDoList = createAsyncThunk(
"todo/fetchList", async (_, { rejectWithValue },{condition:true}) => {
try {
const list = await getList();
return list;
} catch (err) {
return rejectWithValue([], err);
}
});
createAsyncThunk accepts three parameters:
-
type: “todo/fetchList”. The general naming convention followed is {reducerName}/{actionType}
-
payloadCreator: is the callback function (_async (_, { rejectWithValue })=>{}), the first param is the argument which is passed to the callback. The second param is the thunkApi(defined below).
-
options: is an object with two props, *condition *is a callback which returns a bool that can be used to skip execution, dispatchConditionRejection uses the condition to dispatch the action. If condition is false *dispatchConditionRejection *will not dispatch any action.
The ThunkApi is important, because you will be depending on the properties defined in it, most of the time. They are:
-
dispatch: dispatching different actions.
-
getState: to access the redux store from within the callback
-
requestId: this is a unique id redux-toolkit generates for each request
-
signal: this can be used to cancel request.
-
rejectWithValue: is a utility function that can return to the action creator a defined payload, in case of error.
-
extra: the “extra argument” given to the thunk middleware on setup, if available
Promise Lifecycle Actions
One of the main reasons I prefer using createAsyncThunk, is for the lifecycle actions it provides. The three lifecycles for an action are as follows:
-
pending: before the callback is called in the payloadCreator
-
fulfilled: on successful executions
-
rejected: in case of an error
[fetchToDoList.fulfilled]: (state, { meta, payload })=> {
state.todoList = payload;
},
[fetchToDoList.pending]: (state, { meta })=>{
state.loading = "pending";
},
[fetchToDoList.rejected]: (state,{meta,payload,error })=>{
state.error = error;
}
Each lifecycle is passed the reducer state (no the store obj) and the thunk action creator, which contains the payload) (return value) on fulfilled/rejected, meta which contains the requestId and the args passed to the payloadCreator, error in case of rejected.
Let’s look at a simple todoList slice to better understand:
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
//axios api
import { getList, updateTodo, addTodo } from "../../api/todoApi";
const initialState = {
todoList: [],
currentRequestId: "",
loading: "fin",
error: "",
};
export const fetchToDoList = createAsyncThunk(
"todo/fetchList",
async (_, { rejectWithValue }) => {
try {
const list = await getList();
return list;
} catch (err) {
return rejectWithValue([], err);
}
}
);
export const updateToDo = createAsyncThunk(
"todo/updateToDo",
async (todo, { rejectWithValue }) => {
try {
const list = await updateTodo(todo);
return list;
} catch (err) {
return rejectWithValue([], err);
}
}
);
export const addNewTodo = createAsyncThunk(
"todo/addNewTodo",
async (todo, { rejectWithValue }) => {
try {
const list = await addTodo(todo);
return list;
} catch (err) {
return rejectWithValue([], err);
}
}
);
const { actions, reducer } = createSlice({
name: "todo",
initialState,
reducers: {},
extraReducers: {
[fetchToDoList.fulfilled]: (state, { meta, payload }) => {
if (meta.requestId === state.currentRequestId.requestId) {
state.todoList = payload;
state.loading = "fin";
state.currentRequestId = "";
}
},
[fetchToDoList.pending]: (state, { meta }) => {
state.currentRequestId = meta;
state.loading = "pending";
},
[fetchToDoList.rejected]: (state, { meta, payload, error }) => {
if (meta.requestId === state.currentRequestId.requestId) {
state.currentRequestId = meta;
state.loading = "fin";
state.todoList = payload;
state.error = error;
}
},
[updateToDo.fulfilled]: (state, { meta, payload }) => {
if (meta.requestId === state.currentRequestId.requestId) {
state.todoList = payload;
state.loading = "fin";
state.currentRequestId = "";
}
},
[updateToDo.pending]: (state, { meta }) => {
state.currentRequestId = meta;
state.loading = "pending";
},
[updateToDo.rejected]: (state, { meta, payload, error }) => {
if ((meta.requestId === state.currentRequestId.requestId) {
state.currentRequestId = meta;
state.loading = "fin";
state.todoList = payload;
state.error = error;
}
},
[addNewTodo.fulfilled]: (state, { meta, payload }) => {
if (meta.requestId === state.currentRequestId.requestId) {
state.todoList = payload;
state.loading = "fin";
state.currentRequestId = "";
}
},
[addNewTodo.pending]: (state, { meta }) => {
state.currentRequestId = meta;
state.loading = "pending";
},
[addNewTodo.rejected]: (state, { meta, payload, error }) => {
if (meta.requestId === state.currentRequestId.requestId) {
state.currentRequestId = meta;
state.loading = "fin";
state.todoList = payload;
state.error = error;
}
},
},
});
export default reducer;
With this, we can dispatch our async actions more conveniently, without distributing our logic into multiple files.
You can find the git repo for the todo application here.