An easier way to handle LOADING, ERROR and SUCCESS states in Redux

An easier way to handle LOADING, ERROR and SUCCESS states in Redux

ยท

15 min read

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!!!

giphy.webp

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

source.gif

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?

source (5).gif

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

source (2).gif

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...

source (3).gif

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 โœจ