One of the most complicated tasks in building front-end applications is dealing with external requests. Difficulties come from two sides.
On the one hand, asynchrony itself generates ambiguities, so the standard mechanisms stop working. Redux doesn't know how to work asynchronously, so all the processing of requests takes place outside Redux. In this case, any non-trivial logic for processing asynchronous actions will appear inside React components:
const MyComponent = (props) => {
const onClick = async (todoId) => {
const response = await axios.get(`https://ru.hexlet.io/api/todos/${todoId}`);
const todos = // Retrieving and converting data from the response
dispatch(todosLoaded(todos));
};
// Rendering
};
On the other hand, the network is an unreliable thing. Requests can take a long time or fail, so we should monitor them to ensure we get the correct response. If the request takes a long time, we show a spinner. If the request is interrupted, we display the corresponding warning:
const onClick = async (todoId) => {
// Working with the finite state machine to process any HTTP request
try {
// Starting the loading process
dispatch(todosLoadingStarted());
const response = await axios.get(`https://hexlet.io/api/todos/${todoId}`);
const todos = // Extracting and transforming data from the response
dispatch(todosLoaded(todos));
} catch (e) {
// It's even more complicated because we have to keep track of exactly what went wrong
dispatch(todosLoadingFailed(e.message));
}
};
We write a lot of similar code, even for a few calls. In real applications, the number of calls could be dozens or hundreds. Therefore, you can't do without a ready-made solution. To automate HTTP requests, we need two mechanisms:
- The redux-thunk middleware, already included in Redux Toolkit
- The
createAsyncThunk()
mechanism
The redux-thunk middleware allows asynchronous code within dispatch()
. That way, we can bring the logic of queries and storage updates into separate functions called thunks:
// Note the nested function receives `dispatch`
// This function will be stored outside the component in a slice, for example
export const fetchTodoById = (todoId) => async (dispatch) => {
const response = await axios.get(`https://hexlet.io/todos/${todoId}`);
// We need to perform the necessary normalization and handle errors
dispatch(todosLoaded(response.todos));
}
// The usage
const TodoComponent = ({ todoId }) => {
const dispatch = useDispatch();
const onFetchClicked = () => {
// Passing the asynchronous function
dispatch(fetchTodoById(todoId));
};
// Around here, we use `onFetchClicked`
}
You can do roughly the same thing without redux-thunk just by writing an asynchronous function to which we pass dispatch()
as input. The difference shows up in the more advanced use cases. For example, when we work with state or global objects, such as a WebSocket connection. In this case, you have to use redux-thunk, which makes this all much easier to do:
// `(dispatch, getState, extraArgument)`
export const fetchTodoById = (todoId) => async (dispatch, getState, extraArgument) => {
// Here is any data transmitted during the middleware configuration phase
const { serviceApi } = extraArgument;
const response = await serviceApi.getTodo(todoId);
dispatch(todosLoaded(response.todos));
};
Despite the convenience gained, thunks do not reduce the amount of code, and the same error handling will make up a large part of the code. This is where createAsyncThunk()
:
import { createAsyncThunk, createSlice, createEntityAdapter } from '@reduxjs/toolkit';
// To avoid hardcoding URLs, we can create a module and create URLs in it
import { getUserUrl } from './routes.js';
// Creating our thunk
export const fetchUserById = createAsyncThunk(
'users/fetchUserById', // It is displayed in dev tools and must be unique for everyone
async (userId) => {
// Only query logic and data return here
// No error handling
const response = await axios.get(getUserUrl(userId));
return response.data;
}
);
const usersAdapter = createEntityAdapter();
const usersSlice = createSlice({
name: 'users',
// Adding loading process tracking to the state
// { ids: [], entities: {}, loading: 'idle', error: null }
initialState: usersAdapter.getInitialState({ loading: 'idle', error: null }),
reducers: {
// Here are any reducers we need
},
extraReducers: (builder) => {
builder
// We call it just before the execution of the query
.addCase(fetchUserById.pending, (state) => {
state.loading = 'loading';
state.error = null;
})
// We call this if the query is successful
.addCase(fetchUserById.fulfilled, (state, action) => {
// Adding a user
usersAdapter.addOne(state, action.payload);
state.loading = 'idle';
state.error = null;
})
// We call it in case of an error
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = 'failed';
// https://redux-toolkit.js.org/api/createAsyncThunk#handling-thunk-errors
state.error = action.error;
});
},
})
// Somewhere in the application
import { fetchUserById } from './slices/usersSlice.js';
// Inside the component
dispatch(fetchUserById(123));
Each thunk created with createAsyncThunk()
contains three reducers: pending, fulfilled, and rejected. They correspond to promise states and are called by Redux Toolkit the moment the promise enters one of these states. We don't have to respond to all, so we can choose what is important to us in the application.
Recommended materials
Are there any more questions? Ask them in the Discussion section.
The Hexlet support team or other students will answer you.
For full access to the course you need a professional subscription.
A professional subscription will give you full access to all Hexlet courses, projects and lifetime access to the theory of lessons learned. You can cancel your subscription at any time.