React - Using Custom Hooks to Reuse Stateful Logic

omarmoataz

Omar Moataz Attia

Posted on August 2, 2020

React - Using Custom Hooks to Reuse Stateful Logic

Alt Text

Today, we're gonna take a look at how we can share stateful logic between react functional components using a custom hook we're gonna call useHttp. This component will be responsible for the state logic related to calling APIs.

The problem

We have a component that makes a GET request

const ArticleDetailsPage = (props) => {
 const [article, setArticle] = useState(props.article);
 const [isLoading, setIsLoading] = useState(true);
 const { id } = useParams();

 useEffect(() => {
   const getData = async () => {
     const articleDetailsAPI = `/posts/${id}`;

     const response = await requester({
       method: "GET",
       url: articleDetailsAPI
     });

     setArticle(response.data);
     setIsLoading(false);
   };
   getData(id);
 }, []);

 if (!isLoading) return <PostDetails post={article} />;
 else return <FooterInfo content="Loading article..." />;
};
Enter fullscreen mode Exit fullscreen mode

And another one that makes a POST request like so:

const publishArticle = async (values) => {
   let articleData = new FormData();
   try {
     articleData.set("content", values.content);
     articleData.set("title", values.title);
     articleData.set("description", values.description);
     articleData.set("thumbnail", values.thumbnail);
     const response = await requester({
       method: "POST",
       url: "/posts",
       data: articleData
     });
     const articleId = response.data.id;
     props.history.push(`/${articleId}`);
   } catch (e) {
     // do something.
   }
 };
Enter fullscreen mode Exit fullscreen mode

Let’s take a look at the core difference between these 2 requests or components.

Right off the bat, one of them creates a GET request and the other one creates a POST request which means one of them needs to send data as part of the request body and one of them doesn’t. Other than that, they’re essentially the same, they both need to display some kind of loading state during request load time and then display some data based on the success or failure of that request i.e. they need to keep track of 3 things: loading, response, and error states.

Now, this logic is very common across every app that makes API calls. We don't want to rewrite it of every component that calls an API.

useHttp to the Rescue

We need a React component that keeps track of these things for us but that component is meant to be used as a hook i.e. it hooks into other functional components to give them extra functionality exactly like what useState and useEffect do. We can call that component whatever we want but since we want to make it obvious that it’s a hook, we’re gonna follow the same naming convention of useState and useEffect and call our component useHttp.

const useHttp = (props) => {
 const { url, method } = props;

 const [isLoading, setLoading] = useState(true);
 const [response, setResponse] = useState({});
 const [error, setError] = useState(null);

 return [response, error, isLoading];
}
Enter fullscreen mode Exit fullscreen mode

Here’s the input and output of useHttp, we give it a url and a method (GET or POST) and we expect it to return the 3 things we talked about earlier: response, error, and loading states.

We’re going to add functionality to send content in the request body to support POST requests a little later but let’s get it working with GET requests first.

We wanna do something like this:

const getResponse = async () => {
     try {
       setLoading(true);
       const response = await requester({
         method,
         url
       });
       setResponse(response);
       setLoading(false);
     } catch(e) {
       setError(e);
       setLoading(false);
     }
   }
Enter fullscreen mode Exit fullscreen mode

We don’t just wanna call this once we want to trigger rerenders based on changes to the 3 variables we’re tracking. We also want to reset everything if we change either the url or the http method we're using to make the request.

For this we can utilize the useEffect and useState hooks to handle both cases:

const useHttp = (props) => {
 const { url, method } = props;

 const [isLoading, setLoading] = useState(true);
 const [response, setResponse] = useState({});
 const [error, setError] = useState(null);

 useEffect(() => {
   const getResponse = async () => {
     try {
       setLoading(true);
       const response = await requester({
         method,
         url
       });
       setResponse(response);
       setLoading(false);
     } catch(e) {
       setError(e);
       setLoading(false);
     }
   }
   getResponse();
 }, [url, method]);

 return [response, error, isLoading];
}
Enter fullscreen mode Exit fullscreen mode

What’s happening here is that we’re setting state to trigger rerenders of the components that are using the useHttp hook but we’re also tracking changes to the props which are method and url in this case.

Now, let’s handle the case where we need to pass in the request body content in case of a post request and use the useHttp hook with the POST request we showed earlier.

I haven’t gotten into how the requester works but it’s based on axios and it has the exact same interface so it already does accept data as an argument to be passed as content in the POST request body. All we need to do is allow the data to be passed to the useHttp hook like so:

const useHttp = (props) => {
 const { url, method, data } = props;

 const [isLoading, setLoading] = useState(true);
 const [response, setResponse] = useState({});
 const [error, setError] = useState(null);

 useEffect(() => {
   setLoading(true);
   const response = requester({
     method,
     url,
     data
   })
     .then(() => {
       setResponse(response);
       setLoading(false);
     })
     .catch((e) => {
       setError(e);
       setLoading(false);
     });
 }, [url, method, data]);

 return [response, error, isLoading];
};
Enter fullscreen mode Exit fullscreen mode

Walking Right Into a Hook Violation

Perfect, right? Well, not really because if we think about the way we need to call our POST request, they’re based on an onClick event handlers and one of the limitations of React hooks is “Do not call in event handlers.” so this code clearly violates that.

// Definitely not an onClick event handler
 const publishArticle = async (values) => {
   let articleData = new FormData();

   articleData.set("content", values.content);
   articleData.set("title", values.title);
   articleData.set("description", values.description);
   articleData.set("thumbnail", values.thumbnail);
   const [response, error, isLoading] = useHttp({
     method: "POST",
     url: "/posts",
     data: articleData
   });
   const articleId = response.data.id;
   props.history.push(`/${articleId}`);
 };

Enter fullscreen mode Exit fullscreen mode

Now we need to think of a possible solution to this problem. We need to call the hook on the root of the component not inside an event handler but we want to trigger the API call onClick.

A Not-so-smart Solution

What if we modify the interface of useHttp slightly and make it return a method that triggers the API call and have that method return the 3 states we wanted to handle? Let’s take a look!

const useHttp = (props) => {
 const { url, method, data } = props;

 const [isLoading, setLoading] = useState(true);
 const [response, setResponse] = useState({});
 const [error, setError] = useState(null);

 const triggerRequest = () => {
   return [response, error, isLoading];
 }

 return triggerRequest;
};
Enter fullscreen mode Exit fullscreen mode

We want something like this, it allows us to call the useHttp hook without triggering the request, great!

The first thought I had about this was let’s send the data to something like a triggerRequest function inside the useHttp hook.

const useHttp = (props) => {
 const { url, method } = props;

 const [isLoading, setLoading] = useState(true);
 const [response, setResponse] = useState({});
 const [error, setError] = useState(null);

 const triggerRequest = async (data) => {
   setLoading(true);
   try {
     setLoading(true);
     const responseData = await requester({
       method,
       url,
       data
     });
     setResponse(responseData);
   } catch(e) {
     setError(e);
   } finally {
     setLoading(false);
   }

   return [response, error, isLoading];
};

 return triggerRequest;
};

Enter fullscreen mode Exit fullscreen mode

This function manages the calling API part well and it does set the state but it doesn't manage the changes that happen after the API is called. By the time the API returns data, the code calling the useHttp hook will have already executed and it's no longer waiting to receive the response. We're close but we're not there yet.

A Better Solution - Back to useEffect

How can we leverage the power of useEffect to do the heavy-lifting for us? We can use it to work with get requests the beautiful way we showed earlier while also having the flexibility to pass data to it without violating hook rules we discussed earlier.

const useHttp = (props) => {
 const { url, method, data, isDelayedRequest } = props;

 const [isLoading, setLoading] = useState(false);
 const [response, setResponse] = useState(null);
 const [error, setError] = useState(null);

 useEffect(() => {
   if (data) {
     triggerRequest();
   }
 }, [data]);

 useEffect(() => {
   if (!isDelayedRequest) {
     triggerRequest();
   }
 }, []);

const triggerRequest = async () => {
   try {
     setLoading(true);
     const responseData = await requester({
       method,
       url,
       data
     });
     setResponse(responseData);
   } catch(e) {
     setError(e);
   } finally {
     setLoading(false);
   }
 };

 return [response, error, isLoading];
};

Enter fullscreen mode Exit fullscreen mode

We added a flag called isDelayedRequest whose job is to tell the useHttp hook whether it should call the API immediately or later (like our POST request).

Now the code that triggers the POST request will look like this:

const [response, error, isLoading] = useHttp({
    method: "POST",
    url: "/posts",
    data: articleData,
    isDelayedRequest: true
  })

  useEffect(() => {
    if (response) {
      const articleId = response.data.id;
      props.history.push(`/${articleId}`);
    }
  }, [response]);

  const publishArticle = async (values) => {
    let articleFormData = new FormData();

    try {
      articleFormData.set("content", values.content);
      articleFormData.set("title", values.title);
      articleFormData.set("description", values.description);
      articleFormData.set("thumbnail", values.thumbnail);

      setArticleData(articleFormData); // triggers the request.
    } catch (e) {
      console.log(`Something went wrong while creating article! ${e}`);
    }
  };
Enter fullscreen mode Exit fullscreen mode

the useEffect hook here is responsible for performing the action after the POST request succeeds since it's triggered when the response changes from the useHttp hook.

Alright, that's all folks! Let me know what you think in the comments and tell me how you use custom hooks to make your life easier. I'm always looking for inspiration.

You can check the code for this article here

This feature was written for creative outlet, a side project I created to share my thoughts with the world while learning about software.

Creative Outlet is open source, you can find the frontend repo
here
or the backend repo here

Till next time,
Omar

💖 💪 🙅 🚩
omarmoataz
Omar Moataz Attia

Posted on August 2, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related