Aman Scripts

Aman Scripts

Creating React Hook for Fetching Data

Subscribe to my newsletter and never miss my upcoming articles

When you write the same piece of code, more than 1 time with minor differences, it’s a good indication that you need to create a more generic version? This is where you can benefit from creating a custom React Hook.

Probably the most frequent task in front-end web development is integration with external APIs to populate your application with data.

Prefer a Video Walk-Through Instead?

Requirements for useData hook

  • Must be able to fetch data from an API for any query
  • Must indicate when data is loading
  • Must return a proper error when the fetch failed
  • Must not make unnecessary calls to APIs

So let’s tackle these one by one!

The Component

For our small test here, we will be calling this particular HTTP GET API from algolia.com. It takes in a "query" with keywords, and returns a list of convenient articles that match.

For example, the list below will return a list of articles related to "react hooks" https://hn.algolia.com/api/v1/search?query=react%20hooks

We use "useState" and "useEffect" to manage our state, and call the API once the query changes from the input component.

function DisplayArticlesList() {
  // save query is used for inputs
  const [query, setQuery] = useState("react hooks");
  // data from the API to display on list
  const [data, setData] = useState([]);

  const handleQueryChange = (event) => {
    setQuery(event.target.value);
  }

  // we only need this to run when “query” changes
  useEffect(() => {
    async function fetchData() {
      const response = await axios.get(`https://hn.algolia.com/api/v1/search?query=${query}`);
      setData(response.data.hits);
    }
    fetchData();
  }, [query]);

  return (<div>
    <input  type="text" 
            value={query}
            onChange={handleQueryChange} />

      <ul>{data.map(article => (
        <li><a href={article.url}>{article.title}</a></li>
      ))}</ul>
  </div>)
}

Remember: useEffect cannot take asynchronous function as input, so we have to create a new function within useEffect, and call it immediately. It is also recommend to initialize these functions inside the hook. Learn more about that here.

The code above will display something like this (with some minor css):

article list component

It works, right?

Yes - this component will work just fine, and you can keep it the way it is. You could even implement all the features that we needed in the useData hook above: loading and error state. But: when you think about it, any component which needs to fetch data from an REST API will need to have some parameters, an error state, and a loading state. So why not create a reusable hook to reduce code duplication?

Let's start by abstracting out some of the logic that we initially wrote in the Display component itself. We will create a new function useData(initialQuery), and move out some of the code from our component:

function useData(initial) {

  const [query, setQuery] = useState("react hooks");
  const [data, setData] = useState([]);

  useEffect(() => {
    async function fetchData() {
      const response = await axios.get(`https://hn.algolia.com/api/v1/search?query=${query}`);
      setData(response.data.hits);
      console.log(response.data.hits)
    }
    fetchData();
  }, [query]);

  return [query, setQuery, data];
}

Notice that we return an array with 3 references: current query, setQuery function to update the query, and the current data. We do not need to expose the setData function, since it is only used internal to our hook. We can now update our function using our new hook:

function DisplayArticlesList() {

  const [query, setQuery, data] = useData("react hooks")

  const handleQueryChange = (event) => {
    setQuery(event.target.value)
  }

  return (<div> /* same as before*/</div>)
}

Now, our display component has shrunk by a lot! That's great, the logic is separated, and reusable. But we can't stop here, let's improve our logic some more to include all the requirements.

We can add a few things:

  • Create a variable using the useState hook for loading. In our useEffect hook implementation, we can call setLoading(true) since our API call is the next line. And, after we call setData, we can set the loading to false, since the response was received and stored in state.
  • Create a similar variable for error. We need to also remember to setError(false) before each API call, so that we can clear the error state before our next result.
  • Since fetchData function is an async function, the error state can be set in the catch block for our fetchData() function call.
  • We also expose the 2 new variables, error and loading, in our array which can be de-structured in our component later.
function useData(initial) {

  const [query, setQuery] = useState("react hooks");
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  useEffect(() => {
    async function fetchData() {
      setLoading(true);
      if(error) setError("");
      const response = await axios.get(`https://hn.algolia.com/api/v1/search?query=${query}`);
      setData(response.data.hits);
      setLoading(false);
    }
    fetchData().catch(error => {
      setError(error.message);
      setLoading(false);
    });
  }, [query]);

  return [query, setQuery, data, loading, error];
}

Unnecessary REST Calls or Unexpected results

You might have noticed that we call setQuery every single keystroke. You can update this by having a delay to call, but that might not be enough. Sometimes REST Calls can take a long time, and you might end up calling the same API multiple times. This is where a cancelToken comes in. This will make sure that we are not putting unnecessary load on the server and client, by making multiple API calls.

First, we need to create a state to persist the cancelToken:

const [token, setToken] = useState(undefined);

Then, the goal is to cancel the previous request before the next API call is initiated (if it exists):

// If token exists, cancel it
// We can also ignore any errors that have the message as "REQUEST_CANCELLED"
if (token) { token.cancel("REQUEST_CANCELLED"); }

// generate new token for current request
const cancelToken = axios.CancelToken.source();
setToken(cancelToken);
const result = await axios(url, {cancelToken: cancelToken.token});

// remove the token, since the request is finished
setToken(undefined)

Final Code (TypeScript compatible)

useData.tsx

import { useState, useEffect } from "react";
import axios, { CancelToken, CancelTokenSource, AxiosError } from "axios";

function useData(url: string) {
  const [query, setQuery] = useState<{ [key: string]: string }>({
    query: "react hooks"
  });
  const [data, setData] = useState<any[]>([]);

  // loading
  const [loading, setLoading] = useState<boolean>(false);

  // error state
  const [error, setError] = useState<string>("");

  // cancel token
  const [token, setToken] = useState<CancelTokenSource>(undefined);

  // query changed, make API call
  useEffect(() => {
    if (token) {
      token.cancel("REQUEST_CANCELLED");
    }
    async function fetchData() {
      setError("");
      setLoading(true);
      setData([]);
      const token: CancelTokenSource = axios.CancelToken.source();
      setToken(token);

      const response = await axios.get(url, {
        cancelToken: token.token,
        params: query
      });

      setToken(undefined);
      setData(response.data.hits);
      setLoading(false);
    }
    fetchData().catch((error: AxiosError) => {
      if (error.message !== "REQUEST_CANCELLED") {
        const msg: string = error.message;
        setError(msg);
      }
      setToken(undefined);
    });
  }, [query]);

  return [data, query, setQuery, loading, error] as const;
}

export default useData;
}

Component Code

import React from "react";
import useData from "./../hooks/useData";

function ArticleSearch() {
  const [data, query, setQuery, loading, error] = useData(
    "https://hn.algolia.com/api/v1/search"
  );

  // Update query on input
  const handleQueryChange = event => {
    const params = {
      query: event.target.value || ""
    };
    setQuery(params);
  };

  return (
    <div>
      <input type="text" value={query.query} onChange={handleQueryChange} />

      {error && <div>`There was an error: ${error}`</div>}

      {loading && <div>Loading...</div>}

      <ul>
        {data.map(article => (
          <li>
            <a href={article.url}>{article.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default ArticleSearch;

Your Assignment!

So, this works for calling the same REST API with a GET request, what if you had to also use this hook for a different GET API? Would you write a brand new hook, or manipulate this one to handle different hosts or APIs? There is no obvious right or wrong answer, depending on the situation, you can choose one.

Let me know your thoughts on how you can achieve using the same hook, but with a different host or endpoint.

#reactjs#javascript#fetch#rest-api#axios
 
Share this