Fetching data with React hooks

September 01, 2021

The idea of this post is to introduce the basic concepts necessary to consume apis using the React hooks. We will start with the simplest solutions and end up implementing a custom hook that can be reused within our app.

One simple example could look like this: we call the api we want to retrieve the data from using the fetch function and once the request has been made, we store the state with the retrieved result. This request could actually happen at different/multiple points of the app flow, like during the component initialization like this case or triggered by a specific user's action.

const [profile, setProfile] = useState();

useEffect(() => {
  fetch('http://api.example.com/profile.json')
    .then((response) => response.json())
    .then((data) => setProfile(data));
}, []);

if (!profile) return null;

return (
  <div>{profile.name}</div>
);

Because the state has changed, the component will be rendered again and it will be able to use the updated value. If we had just assigned the new value to a plain variable, the component would get "stuck" with its initial value(null).

Then, what if we need to make another request to a different endpoint? The most immediate approach would be just to create a new state variable and change it with the result of the new api request:

const [profile, setProfile] = useState();
const [vehicles, setVehicles] = useState();

useEffect(() => {
  fetch('https://api.example.com/profile.json')
    .then((response) => response.json())
    .then((data) => setProfile(data));

  fetch('https://api.example.com/vehicles.json')
    .then((response) => response.json())
    .then((data) => setVehicles(data));
}, []);

It's obvious that although this code is pretty simple and easy to understand, it can get quite repetitive and therefore, harder to maintain. There are a bunch of options to help mitigate this issue and make it more reusable. For example, all those requests could be encapsulated in a simple api client like this:

api.js
const BASE_URL = 'https://api.example.com';
const ROUTES = {
  profile: '/profile.json',
  vehicles: '/vehicles.json'
};

const api = {};

Object.entries(ROUTES).forEach(([key, route]) => {
  api[key] = async () =>  {
    const response = await fetch(`${BASE_URL}${route}`);
    const data = await response.json();
    return data;
  }
});

export default api;

For the sake of simplicity, I have omitted a bunch of important stuff such the auth headers, http verbs, query params... etc.

Now the api client can be used like this:

const [profile, setProfile] = useState();
const [vehicles, setVehicles] = useState();

useEffect(async () => {
  api.profile().then(p => setProfile(p));
  api.vehicles().then(v => setVehicles(v));
}, []);

There is still a repetitive part that could be abstracted which is the state management and here is where we can start creating our own custom hook:

useApi.js

import { useState } from 'react';
import api from '../api';

export default function useApi(method) {
  const [state, setState] = useState();

  const request = async () => {
    const response  = await api[method]();
    setState(response);
  }

  return [
    state,
    request,
  ]
}

The hook is basically a function that exposes the state that will contain the request result and a function that will actually make the api request and set the state. Now, the component would look like this:

const [profile, profileApi] = useApi('profile');
const [vehicles, vehiclesApi] = useApi('vehicles');

useEffect(async () => {
  profileApi();
  vehiclesApi();
}, []);

There is still something I don't like that much about this approach which is that we will have a different "client" for each potential request.

One way to fix this could be to make the hook expose a proxy object that will capture all method calls and then call the real api client and store the result in the inner state, which will be exposed to be used in our component:

useApi.js
import { useState } from 'react';
import api from '../api';

function useApi() {
  const [state, setState] = useState({});
  const proxy = new Proxy({}, {
    get: (target, prop) => {
      return async () => {
        const response = await api[prop]();
        setState((prevState) => ({ ...prevState, [prop]: response }));
      }
    }
  });

  return [proxy, state];
}

export default useApi;

And eventually the component would look like this:

const [api, { profile, vehicles }] = useApi();

useEffect(() => {
  api.profile();
  api.vehicles();
}, []);

And that's pretty much it. With a simple change in our hook implementation, we can expose a more convenient interface.

Loading status and errors

The next improvement we might want to consider would be to manage the loading and error status. Basically there would be two approaches. We could expose a different loading/error state for each request or just have a single loading/error status which will return true if any of the requests is still being processed or if any of the requests raised an error. The final implementation might depend on your own requirements, but if we wanted to implement the second approach, it could look to something like this:

import { useState } from 'react';
import api from '../api';

function useApi() {
  const [response, setResponse] = useState({});
  const [status, setStatus] = useState({});
  const proxy = new Proxy({}, {
    get: (target, prop) => {
      return async () => {
        try {
          setStatus(prevStatus => ({ ...prevStatus, [prop]: { isLoading: true, error: null } }));
          const response = await api[prop]();
          setResponse(prevResponse => ({ ...prevResponse, [prop]: response }));
          setStatus(prevStatus => ({ ...prevStatus, [prop]: { isLoading: false, error: null } }));
        } catch (err) {
          setStatus(prevStatus => ({ ...prevStatus, [prop]: { isLoading: false, error: err } }));
        }
      }
    }
  });

  const statusSummary = Object.values(status).reduce((acc, s) => {
    if (s.isLoading) acc.isLoading = true;
    if (s.error) acc.error = s.error;
    return acc;
  }, {});

  return [
    proxy,
    response,
    statusSummary,
  ];
}
export default useApi;

As you can see, we are just storing a different status for each request in order to avoid race conditions and eventually exposing if any of those requests is still pending or raised an error.

Eventually, those new properties will be available within our component ready to be used:

const [api, { profile, vehicles }, { isLoading, error }] = useApi();

useEffect(() => {
  api.profile();
  api.vehicles();
}, []);

if (isLoading) return (<div>Loading...</div>);

return (
  <div>
    {error && <div>Error!</div>}
    {profile && <div>{profile.firstName}</div>}
    {vehicles && (
      <ul>
        {vehicles.map((vehicle) => (
          <li key={vehicle.id}>{vehicle.plate}</li>
        ))}
      </ul>
    )}
  </div>
);