Gracefully Fetch API Data With React and TypeScript

Fetching API data from a component using React requires handling loading, error, auth, and success states. This makes fetching data difficult to abstract and integrate well into the hooks in a natural way. Further, it’s easy to make mistakes calling function components with the wrong props only to find out at run time.

To solve this, we need to implement three things:

  1. A hook that calls the API and returns data representing the status (loading, error, success, etc).
  2. A higher-order component that handles rendering API states (e.g. a loading indicator) and maps data from a successful API call to a passed in component’s props.
  3. A component for displaying data from the API without having to duplicate loading states and errors.

See also

Implementing a useApi hook

We want to represent our API call with data instead of callbacks and managing state manually. To do that, we’ll create our own hook that makes an API call, handles auth errors (like refreshing an auth token), and returning data from the API response.

Here’s an example that (ab)uses the useEffect hook to make an API call and automatically refresh tokens and retry if there was a recoverable auth error.

Note: refreshToken would be some other fetch call to acquire a new API access token—a common scenario for token based authentication in a web app.

export enum ApiStatus {
  // API request is being made
  Loading,
  // API call was successful
  Success,
  // API call resulted in an unauthorized error even after attempting
  // a token refresh
  ErrorUnauthorized,
  // API resulted in an error
  Error,
  // The initial request failed and we are attempting to refresh an
  // access token
  RefreshingToken,
  // We have new access token and will attempt to make a request
  // again. Note: if the retry fails the status will be `Error`.
  Retrying,
}

interface IApiData {
  status: ApiStatus
  error: any,
  data: any,
}

/*
Hook for fetching data from the backend API. Returns an `IApiData`
object. See `ApiStatus` for which states need to be handled.

API calls that fail due to an unauthorized error (expired token) are
automatically retried after attempting to refresh an access token.

To do that (and avoid infinite loops) this is essentially a state
machine that supports the following ApiStatus transitions:
  Loading -> Success
  Loading -> Error
  Loading -> RefreshingToken
  RefreshingToken -> Retrying
  RefreshingToken -> Error
  RefreshingToken -> ErrorUnauthorized
  Retrying -> Success
  Retrying -> Error
  Retrying -> ErrorUnauthorized
*/
export const useApi = (url: string, body = {}) => {
  const [retryToggle, setRetryToggle] = useState(false);
  const [data, setData] = React.useState<IApiData>({
    status: ApiStatus.Loading,
    error: null,
    data: null,
  });

  React.useEffect(() => {
    if (data.status === ApiStatus.RefreshingToken) {
      // Try refreshing the access token and retrying the request
      console.log('Attempting to refresh access token')
      myRefreshTokenFn().then(() => {
        setData({
          status: ApiStatus.Retrying,
          data: null,
          error: null,
        });

        // Trigger a retry
        setRetryToggle((i: boolean) => !i);
      }).catch((err: MyRefreshTokenError) => {
        // Handle errors and set the the API status accordingly
        if (err === MyRefreshTokenError.Expired) {
          setData({
            status: ApiStatus.ErrorUnauthorized,
            data: null,
            error: err
          });
        } else {
          setData({
            status: ApiStatus.Error,
            data: null,
            error: err
          });
        }
      });
      return;
    }
;
    const authToken = myAuthTokenFn();
    const request: RequestInit = {
      method: 'POST',
      body: JSON.stringify(body),
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${authToken}`,
      },
    }

    fetch(url, request)
      .then((response) => {
        if (!response.ok) {
          throw Error(response.statusText);
        }
        return response.json()
      })
      .then((data) => {
        setData({
          status: ApiStatus.Success,
          error: null,
          data
        });
      })
      .catch((err: Error) => {
        // The only way this could happen is if something is broken
        // with the refresh token process (e.g. we get a new access
        // token, but it's invalid due to some coding error). We
        // explicitely disallow going from Retrying -> RefreshingToken
        // in order to avoid a potential infinite loop.
        if (data.status === ApiStatus.Retrying) {
          console.log('Unauthorized. Not retrying:', data.status);
          setData({
            status: ApiStatus.ErrorUnauthorized,
            data: null,
            error: err
          });
          return;
        }

        switch (err.message) {
          // Recover from an unauthorized error by triggering a token
          // refresh.
          case 'Unauthorized':
            setData({
              status: ApiStatus.RefreshingToken,
              data: null,
              error: err
            });

            // Trigger the effect again
            setRetryToggle((i: boolean) => !i);
            break;
          default:
            setData({
              status: ApiStatus.Error,
              data: null,
              error: err
            });
        }
      });
    // This dependency allows us to re-run the effect whenever this
    // value changes.
  }, [retryToggle]);

  return data;
}

Here’s how to use it in a component:

export const FooComponent: FunctionComponent = () => {
  let { status, error, data } = useApi(`https://myapi/resource`);

  return (
    ...
  )
}

However, we now need to handle all possible variants of ApiStatus which would result in a large amount of boilerplate code to render things like loading states and logging out because of an auth error.

Higher-order component

To reduce the boilerplate we’ll introduce a higher order component that handles all the API states and calls or component that needed the API data in the first place.

type ApiStatusHandlerProps<T> = {
  status: ApiStatus,
  error: Error,
  data: any,
  component: FunctionComponent<T>,
}

/*
   Higher order component that handles API request states for loading,
   error, complete. This should be paired with the `useApi` hook.

   Generic type `P` is the props type to pass to `FunctionComponent`
   specified by `component` if the API request is successful. This
   provides type safety because you will get a type error if the props
   type doesn't match the component.
 */
export function ApiStatusHandler<P>(
  { status, data, component }: ApiStatusHandlerProps<P>
): React.ReactElement<P> | null {
  switch (status) {
    case ApiStatus.ErrorUnauthorized:
      /* Handle logging out */
      return (<p>Logging you out </p>);
    case ApiStatus.Error:
      /* Handle displaying an error */
      return (<p>Big error!</p>);
    case ApiStatus.Loading:
    case ApiStatus.Retrying:
    case ApiStatus.RefreshingToken:
      /* Show loading */
      return (<p>Loading</p>);
    case ApiStatus.Success:
      /* Call our component with data from the API response */
      return component(data);

    // Compile time error if we haven't covered all ApiStatus variants
    default: ((x: never) => { throw new Error(status + " was unhandled."); })(status);
  }
}

Now we can use the ApiStatusHandler like this:

export const FooComponent: FunctionComponent = () => {
  let { status, error, data } = useApi(`https://myapi/resource`);

  return (
    <ApiStatusHandler<BarProps> status={status} error={error} data={data} component={BarComponent} />
  )
}

A component for rendering content from the API call

The component called by ApiStatusHandler gets passed in props from the API response. Since ApiStatusHandler is parameterized by the component’s prop type we get some additional type safety—you can’t accidentally call component with the wrong props or you’ll get a compile time error.

(In practice you might also want to have a function to translate the API response data to props)

type BarProps = {
    status: string
}

const BarComponent: FunctionComponent<BarProps> ({status}) => (
  <p>{status}</p>
)