In my previous blog post, I talked about Redux and broke down creating a slice to manage state in your applications. In this blog post, we are going to talk about another important tool in the Redux Toolkit called 'createAsyncThunk.'
What is 'createAsyncThunk'?
Other than being an obnoxious term to say and type, createAsyncThunk is a function from Redux's Toolkit to help manage asynchronous actions and data fetching. It's another tool to help reduce boilerplate code by generating action types for each stage of the async request (pending, fulfilled, rejected).
To implement createAsyncThunk into your slice, start by importing it the same way you import createSlice from the Redux Toolkit. The code below is an example from a project of mine where I created a houseSlice (each house has an address and information about the residents). Instead of fetching the data in my React component, it's in the slice:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchHouses = createAsyncThunk('houses/fetchHouses', async () => {
const response = await fetch('/api/houses');
const data = await response.json();
return data;
});
Breaking this down, we are exporting and defining our thunk for the house data. houses/fetchHouses
is an action type string that uniquely identifies a specific action to be dispatched. Next, we define the logic for the async operation. Setting the response as a variable, this is where we include the fetch for the data. Similarly, we parse the JSON response body and return the data, which then becomes the "fulfilled" action payload.
If we were going to fetch the data in our component, it would look like this:
function Houses() {
//add state
const [houses, setHouses] = useState([]);
useEffect(() => {
fetch('/api/houses')
.then(res) => res.json())
.then((data) => {
setHouses(data)
});
}, [])
This is a really simple fetch request, however, when you have other things you need to add in, like maybe a request to handle errors, it can take up a lot of space in your component.
Let's add reducers to our slice, but utilizing extraReducers:
const housesSlice = createSlice({
name: 'houses',
initialState: [],
reducers: {},
extraReducers: {
[fetchHouses.pending]: (state) => {
state.loading = true;
state.error = null;
},
[fetchHouses.fulfilled]: (state, action) => {
return action.payload;
},
[fetchHouses.rejected]: (state, action) => {
state.loading = false;
state.error = action.error.message;
},
}
});
export default housesSlice.reducer;
extraReducers, why?
If you read my previous blog post, this code will look familiar to you. Quick run through of the first 3 lines: defining the slice, naming the slice, and setting the initial state of the slice. Normally, your reducers would go in the reducer object, and each reducer you want to use would need to be exported to be used in the components. Here, we are setting our reducers as an empty object and handling actions via extraReducers. ExtraReducers allow the handling of actions defined outside of the slice. It's useful for handling async operations like the actions created by createAsyncThunk, which are handled outside of the slice but need to be handled by the slice reducer.
If that doesn't make any sense, don't worry, you're not alone. There's a learning curve with Redux and it takes some time to get used to and understand.
The best description I've read to explain the difference between reducers and extraReducers is from a stack overflow question. Here's the answer:
The reducers property both creates an action creator function and responds to that action in the slice reducer. The extraReducers allows you to respond to an action in your slice reducer but does not create an action creator function.
Side note: "pending," "fulfilled," and "rejected" are action types that are built into the createAsyncThunk API, so use those action words and don't change them.
Code explained
In the extraReducers, we are handling the pending state of fetchHouses. The state loading is true because, well, it's loading. And state errors are null because the promise isn't fulfilled or rejected yet:
[fetchHouses.pending]: (state) => {
state.loading = true;
state.error = null;
},
Next, we are handling the fulfilled state of fetchHouses by updating the state with the fetched data and setting loading to false since the request is complete:
[fetchHouses.fulfilled]: (state, action) => {
state.data = action.payload;
state.loading = false;
},
Last, we are handling the rejected state of fetchHouses by setting loading to false and updating the state with an error message:
[fetchHouses.rejected]: (state, action) => {
state.loading = false;
state.error = action.error.message;
},
Back in the React component
Now that our slice is done, it's time to add it into our component. This step is so simple you're going to think something is missing:
import { fetchHouses } from '../redux/housesSlice';
function HouseContainer() {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchHouses());
}, [dispatch]);
That's it! When dispatch(fetchHouses())
is called, it initiates the asynchronous operations defined in the fetchHouses thunk. So if the operation is a success, fetchHouses.fulfilled is dispatched, and same with if it's rejected.
If you wanted to access each state, you could by using useSelector. For example:
function HouseContainer() {
const loading = useSelector((state) => state.houses.loading);
const error = useSelector((state) => state.houses.error);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
}
Conclusion
Using createAsyncThunk from Redux is a streamlined and more efficient way to handle asynchronous logic in your Redux applications. It can significantly reduce boilerplate code in your components, and provides a clear structure for async actions. Overall, it's cleaner and more maintainable.
Check out the Redux Toolkit docs at DOCS
Top comments (0)