by Francesco Agnoletto

How to write a custom React fetch hook

With loading and error states

Photo by Michael Olsen on Unsplash

Writing the same fetch code in every component that needs it can be extremely boring, long, and prone to bugs.
Luckily this can be optimized with a custom React hook. Leaving us to handle the render logic only.

The standard hooks

At the end of the day, a custom hook is just a function that calls the standard React hooks inside of it.

We will leverage both useState and useEffect hooks for our custom fetch hook.

We want to make sure the hook allows us to display its status when loading data, failure, or success.
For this reason, we can start by defining the type for our data to handle all three cases.

// loading type has no data nor error
interface ILoadingState {
  status: "loading";
  data: null;
  error: null;
}

// error type has no data but an error
interface IErrorState {
  status: "error";
  data: null;
  error: Error;
}

// success type has data with type provided by user
// and no error
interface ISuccessState<T> {
  status: "success";
  data: T;
  error: null;
}

// By writing 3 interfaces instead of a conditional one
// we are sure that if the status is "success" it will always have data
// and "error" will always have an error
type state<T> = ILoadingState | IErrorState | ISuccessState<T>;

Once the types are done we can jump on drafting the hook.

// we don't know the type returned from the fetch
// so we leave it as any unless the user provides it
function useFetch<S = any>(url: string): state<S> {
  // standard useState hook to save our data
  const [state, setState] = useState<state<S>>({
    status: "loading",
    data: null,
    error: null,
  });

  // standard useEffect to fetch data
  useEffect(() => {
    fetch(url)
      .then((response) => {
        return response.json();
      })
      .then((data: S) => {
        setState({
          status: "success",
          data,
          error: null,
        });
      })
      .catch((error: Error) => {
        setState({
          status: "error",
          data: null,
          error: error,
        });
      });
  }, []);

  return state;
}

As you might notice, all this code is the exact same that would be written in any component needing to fetch data. Nothing special is going on other than the fact we are using these hooks inside a simple function.

I wrote a simple example to test the hook below:

const App = () => {
  const { status, data, error } = useFetch("https://aws.random.cat/meow");

  if (status === "error") {
    return <div>Error: {error.message}</div>;
  } else if (status === "success") {
    return (
      <div>
        <img src={data.file} width={600} />
      </div>
    );
  } else {
    return <div>Loading</div>;
  }
};

The code works! It displays an initial loading, then an error in case of failure and a cat pic in case of success.

What if we want a new cat pic? Unfortunately, this code only works when the component initially renders. It has no way of being called again.

We can improve the hook to allow an optional parameter to re-run useEffect when the value changes.

function useFetch<S = any>(url: string, params: unknown[] = []): state<S> {
  const [state, setState] = useState<state<S>>({
    status: "loading",
    data: null,
    error: null,
  });

  useEffect(() => {
    // each time we re-run useEffect
    // we change the state to loading
    // to provide feedback to the user
    setState({
      status: "loading",
      data: null,
      error: null,
    });
    fetch(url)
      .then((response) => {
        return response.json();
      })
      .then((data: S) => {
        setState({
          status: "success",
          data,
          error: null,
        });
      })
      .catch((error: Error) => {
        setState({
          status: "error",
          data: null,
          error: error,
        });
      });
    // optional array of parameters for re-running the useEffect hook
  }, params);

  return state;
}

The new functionality expands our code without modifying the original hook.

An example of how this code could be used for refreshing data.

const App = () => {
  const [reloading, setReloading] = React.useState<boolean>(false);
  // we define the custom shape of our data
  const { status, data, error } = useFetch<{ file: string }>(
    "https://aws.random.cat/meow",
    // array of parameters
    [reloading]
  );

  // simple hack to refresh our hook
  // when button is pressed
  // the value of reloading changes
  // triggering our custom hook to re-run
  // and give us a new cat pic
  const reloadImage = () => {
    setReloading(!reloading);
  };

  if (status === "error") {
    return <div>Error: {error.message}</div>;
  } else if (status === "success") {
    return (
      <div>
        <img src={data.file} width={600} />
        <button onClick={reloadImage}>New cat!</button>
      </div>
    );
  } else {
    return <div>Loading</div>;
  }
};

And that’s all there is to it.

The full code is available on GitHub.

I also published the fetchHook source code on npm, it is available here, you can simply install it and use as described above.