Hey there ๐๐ฝ,
Firstly, sorry I couldn't come up with a more catchy name ๐ฉ. Believe me, I tried.
We know that the boilerplate for Redux can be a little bit, yunno, annoying... And if you've got lots of states to manage, for maybe a huge awesome app you're working on, using Redux can mean you having to write so much code for not-so-much tasks.
In this article, I'm going to show you an easier and more generic way I discovered to handle ERROR, SUCCESS and LOADING states in React for all the actions you need in Redux. So you can reduce the code in your reducer (pun intended), and know more about how redux does its stuff.
Let's Dive into it!!!
So basically, I'll be using Redux with hooks, so all the useSelector and useDispatch stuff... So you may need to have some experience with Redux to follow along.
If you understand Redux but you don't really understand Redux with Hooks, no worries, you can still keep reading, then you can check out this video later. Also, if you wanna know how react itself works with hooks, check out this one
Now let's assume I have two states that I want to manage in my app: user's details and user's posts
So I guess my state would probably look like this:
const initialState={
userDetails: {},
posts: []
}
Now the actions for userDetails
Normally, the user should only be able to fetch and edit his/her details, so we'd be having GET and PATCH Requests
This is how actions/userDetails.js would look like:
// userDetails.js
import Axios from "axios";
import { MY_BASE_API_URL } from "../../utils/url";
export const fetchUserDetails = () => (dispatch) => {
Axios.get(MY_BASE_API_URL + "/user/user-profile/")
.then((response) => {
dispatch({ type: "FETCH_USER_DETAILS", payload: response.data });
})
.catch((error) => {
console.log(error.response)
});
};
export const updateUserDetails = (editedDetails) => (dispatch) => {
Axios.patch(
MY_BASE_API_URL + "/user/user-profile/" + editedDetails.id + "/", editedDetails )
.then((response) => {
dispatch({ type: "UPDATE_USER_DETAILS", payload: response.data });
})
.catch((error) => {
console.log(error.response)
});
};
Next, the actions for the posts
Lets assume the user can create, fetch, edit and delete posts, so we'd be having GET, POST, PATCH and DELETE Requests
This is how actions/userPosts.js would look like:
// userPosts.js
import Axios from "axios";
import { MY_BASE_API_URL } from "../../utils/url";
export const fetchUserPosts = () => (dispatch) => {
Axios.get(MY_BASE_API_URL + "/posts/")
.then((response) => {
dispatch({ type: "FETCH_USER_POSTS", payload: repsonse.data });
})
.catch((error) => {
console.log(error.response)
});
};
export const createNewPost = (newPostPayload) => (dispatch) => {
Axios.post(MY_BASE_API_URL + "/posts/", newPostPayload)
.then((response) => {
dispatch({ type: "CREATE_NEW_POST", payload: response.data });
})
.catch((error) => {
console.log(error);
});
};
export const updateUserPost = (editedPost) => (dispatch) => {
Axios.put(MY_BASE_API_URL + "/posts/" + editedPost.id + "/", editedPost )
.then((response) => {
dispatch({ type: "UPDATE_USER_POST", payload: response.data });
})
.catch((error) => {
console.log(error.response)
});
};
export const deleteUserPost = (postToDelete) => (dispatch) => {
Axios.delete(MY_BASE_API_URL + "/posts/" + postToDelete.id + "/")
.then((response) => {
dispatch({ type: "DELETE_USER_POST", payload: response.data });
})
.catch((error) => {
console.log(error.response)
});
};
All good! Now how do we access their loading states?
The whole trick is in an extra state value called loadingActions
So here's how it works:
loadingActions
is an array that holds all the actions that are being loaded. Once an action is called, it adds (or removes) a string with the name of the action that is loading, depending on the loading value(true or false)... to the loadingActions
array.
Let us give it a look
We're going to add a loading action to fetchUserDetails:
export const fetchUserDetails = () => (dispatch) => {
//First, we set loading to true
dispatch({
type: "LOADING",
isLoading: true, // The loading state
loadingType: "FETCH_USER_DETAILS", // The loading state type
});
Axios.get(MY_BASE_API_URL + "/user/user-profile/")
.then((response) => {
dispatch({
type: "LOADING",
isLoading: false, // Set the loading state to false after fetching successfully
loadingType: "FETCH_USER_DETAILS", // The loading state type
});
dispatch({ type: "FETCH_USER_DETAILS", payload: response.data });
})
.catch((error) => {
dispatch({
type: "LOADING",
isLoading: false, // Set the loading state to false after fetching failure
loadingType: "FETCH_USER_DETAILS", // The loading state type
});
console.log(error.response)
});
};
Now let's see how the reducer handles this...
Here's our reducers/commonReducer.js
// commonReducer.js
const initialState = {
loadingActions: [],
};
export default function (state = initialState, action) {
switch (action.type) {
case "LOADING":
if (action.isLoading === true) {
return {
...state,
//If isLoading is true, add the loading type to the array
loadingActions: [...state.loadingActions, action.loadingType],
};
} else {
return {
...state,
//If isLoading is false, remove the loading type from the array
loadingActions: state.loadingActions.filter(
(eachAction) => eachAction !== action.loadingType
),
};
}
}
}
So during the fetchUserDetails
phase, the value of loadingActions
moves from
[] (before fetch is called) ==> ['FETCH_USER_DETAILS'] (during fetch) ==> [] (after fetch)
Okay! Let's see how that works
We'll create a page called details.js
where we'll render the user's details:
// details.js
import React, { useState, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { fetchUserDetails } from "../../redux/actions/userDetails.js";
export default function Details(){
const loadingActions = useSelector((state) => state.common.loadingActions );
const userDetails= useSelector((state) => state.details.userDetails);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchUserDetails());
}, []);
return(
<div>
//Now we check if "FETCH_USER_DETAILS" exisits in our loadingActions
{loadingActions.includes("FETCH_USER_DETAILS") ?
<p>Loading......</p>
:
<div>
<h3>{userDetails.name}</h3>
<p>{userDetails.email}</p>
</div>
}
</div>
)
}
The big selling point of this method is that you can use one reducer and state value to handle the loading states for all your data.
So if we wanted to get the loading state for getUserPosts, all we need to do is change 'FETCH_USER_DETAILS' to 'FETCH_USER_POSTS' and check if
loadingActions.includes("FETCH_USER_POSTS")
See?
Now let's take a look at how that can work for error states...
If there was an error during createNewPost, here's what we can do:
export const createNewPost = (newPostPayload) => (dispatch) => {
dispatch({
type: "LOADING",
isLoading: true,
loadingType: "CREATE_NEW_POST",
});
Axios.post(MY_BASE_API_URL + "/posts/", newPostPayload)
.then((response) => {
dispatch({
type: "LOADING",
isLoading: false,
loadingType: "CREATE_NEW_POST",
});
dispatch({ type: "CREATE_NEW_POST", payload: response.data });
})
.catch((error) => {
dispatch({
type: "LOADING",
isLoading: false,
loadingType: "CREATE_NEW_POST",
});
dispatch({
type: "ERROR",
hasError: true, // set error state to true
errorMessage: error.response.data, //pass the error message
errorType: "CREATE_NEW_POST", //the error type
});
console.log(error);
});
};
So basically what we're doing here is similar to that for LOADING. That means we would also have a state value called errorActions
initialized as []
NOTE:
The errorActions would be an array of objects, rather than strings used loadingActions. This is because the error object will store:
- The action of the error (errorType), and
- The error message (errorMessage)
So we can update our reducers/commonReducer.js like this:
// commonReducer.js
const initialState = {
loadingActions: [],
errorActions: [],
};
export default function (state = initialState, action) {
switch (action.type) {
case "LOADING":
if (action.isLoading === true) {
return {
...state,
//If isLoading is true, add the loading type to the array
loadingActions: [...state.loadingActions, action.loadingType],
};
} else {
return {
...state,
//If isLoading is false, remove the loading type from the array
loadingActions: state.loadingActions.filter(
(eachAction) => eachAction !== action.loadingType
),
};
}
case "ERROR":
if (action.hasError === true) {
return {
...state,
// If hasError is true, add error object to the array
errorActions: [
...state.actionsError,
{
errorType: action.errorType,
errorMessage: action.errorMessage,
},
],
};
} else {
return {
...state,
// If hasError is false, remove error object from the array
errorActions: state.errorActions.filter(
(eachError) => eachError.errorType !== action.errorType
),
};
}
}
}
Note how an object is added to the errorActions
array while strings are added to the loadingActions
array. There really is no rule, I just made it this way in order to accommodate for the errorMessage
In order to remove stale data for the next action call, we have to remove the error object for that instance. Don't worry, javascript is still going to know that there was an error initially. So it's just a form of 'clean-up'
So the createNewPost action will look like this:
export const createNewPost = (newPostPayload) => (dispatch) => {
dispatch({
type: "LOADING",
isLoading: true,
loadingType: "CREATE_NEW_POST",
});
Axios.post(MY_BASE_API_URL + "/posts/", newPostPayload)
.then((response) => {
dispatch({
type: "LOADING",
isLoading: false,
loadingType: "CREATE_NEW_POST",
});
dispatch({ type: "CREATE_NEW_POST", payload: response.data });
})
.catch((error) => {
dispatch({
type: "LOADING",
isLoading: false,
loadingType: "CREATE_NEW_POST",
});
dispatch({
type: "ERROR",
hasError: true, // set error state to true
errorMessage: error.response.data, //pass the error message
errorType: "CREATE_NEW_POST", //the error type
});
// Remove the error instance as soon as it's created so we don't keep any leftover error states in our next call
dispatch({
type: "ERROR",
hasError: false, // set error state to false
errorType: "CREATE_NEW_POST", //the error type
});
console.log(error);
});
};
Alright! Now how do we use this in our rendering?
Let's look at a component where the user can submit a post
// createPost.js
import React, { useState, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { createNewPost } from "../../redux/actions/userDetails.js";
export default function CreatePosts(){
const errorActions= useSelector((state) => state.common.errorActions);
const dispatch = useDispatch();
useEffect(() => {
if(actionsError.find((error) => error[errorType] === "CREATE_NEW_POST")){
alert('An error occured!!!')
}
// We use the useEffect hook to check if any error object in the errorActions array have an errorType called "CREATE_NEW_POST"
}, [errorActions]);
const onSubmit=()=>{
const newPostPayload={
title: 'Some title',
body: 'Some body'
}
dispatch(createNewPost(newPostPayload))
}
return(
<div>
<button onClick={()=>onSubmit()} >Click me!</button>
</div>
)
}
Aaand that's it!
This same method can also be used for update and delete actions just like they were used in fetch and post.
You can also use it to get states such as successActions, loadedActions, etc.
Sorry if the code formatting looks weird ๐ฌ, still getting the hang of it...
You can always reach out to me in my email or GitHub or Twitter. Oh oh, check out my website too at adedaniel.netlify.app
PS: This is my very first article, so please leave a like and drop a comment so I can answer any question you've got...
Thank you for reading! You're awesome โจ