A useful pattern for handling remote data in React apps

By Devin Jameson on August 10, 2024

One of the most common things we do in React apps is display remote data fetched from a server. Having worked on mostly React apps for the last several years, I've seen this handled in several different ways and developed some opinions on how to model and handle remote data state for maximum clarity.

First, let's take a look at a common approach. Can you spot the issues?

import { useState, useEffect } from "react";
 
type CatFactData = {
  fact: string;
  length: number;
};
 
type FetchDataError = "NetworkError" | "UnknownError";
 
export default function CatFact() {
  const [data, setData] = useState<CatFactData | undefined>();
  const [error, setError] = useState<FetchDataError | undefined>();
  const [isLoading, setIsLoading] = useState<boolean>(true);
 
  const getData = async () => {
    setIsLoading(true);
 
    try {
      const response = await fetch("https://catfact.ninja/fact");
 
      if (!response.ok) {
        setError("UnknownError");
      } else {
        const catFactData = await response.json();
        setData(catFactData);
      }
    } catch {
      setError("NetworkError");
    } finally {
      setIsLoading(false);
    }
  };
 
  useEffect(() => {
    getData();
  }, []);
 
  if (isLoading) {
    return <p>Loading...</p>;
  }
 
  if (error) {
    return <p>Something went wrong...</p>;
  }
 
  if (data) {
    return (
      <div className="container">
        <h1>Cat Fact</h1>
        <p>{data.fact}</p>
      </div>
    );
  } else {
    return <p>Something went very wrong...</p>;
  }
}

Here are the downsides I see:

  • We have to handle three different slices of state which can get out of sync with each other and create impossible states. For example, we should never be in a state where we have data and are loading data at the same time, but this structure makes that possible.
  • It's possible, according to the type system, to end up in the final else block of our component, which should never happen. This indicates poor modeling of the state in our type system.
  • If we want our loading and error states to also be wrapped in a div with a particular CSS class, we'd need to either repeat that container div three times, pull out a subcomponent, or move the div into the parent component, potentially impacting readability and ergonomics.

Now let's take a look at what, I think, is a more organized and clear approach to managing this component's state. There are a few important things happening in this revised component:

  • We introduce a RemoteData type, which is a discriminated union type. This type enables us to represent our loading, error, and success states with one slice of state instead of three.
  • We introduce an unwrapRemoteData function which enables us to convert any value of the RemoteData type into a value of some other type.
import { useState, useEffect } from "react";
 
type CatFactData = {
  fact: string;
  length: number;
};
 
type FetchDataError = "NetworkError" | "UnknownError";
 
type RemoteData<T, U> = Loading | Success<T> | Failed<U>;
type Loading = {
  kind: "Loading";
};
type Success<T> = {
  kind: "Success";
  data: T;
};
type Failed<U> = {
  kind: "Failed";
  error: U;
};
 
const unwrapRemoteData = <T, U, V>(
  remoteData: RemoteData<T, U>,
  {
    onLoading,
    onSuccess,
    onFailed
  }: {
    onLoading: () => V;
    onSuccess: (data: T) => V;
    onFailed: (error: U) => V;
  }
): V => {
  switch (remoteData.kind) {
    case "Loading":
      return onLoading();
    case "Success":
      return onSuccess(remoteData.data);
    case "Failed":
      return onFailed(remoteData.error);
  }
};
 
export default function CatFact() {
  const [remoteData, setRemoteData] = useState<
    RemoteData<CatFactData, FetchDataError>
  >({ kind: "Loading" });
 
  const getData = async () => {
    setRemoteData({ kind: "Loading" });
 
    try {
      const response = await fetch("https://catfact.ninja/fact");
 
      if (!response.ok) {
        setRemoteData({ kind: "Failed", error: "UnknownError" });
      } else {
        const catFactData = await response.json();
        setRemoteData({ kind: "Success", data: catFactData });
      }
    } catch {
      setRemoteData({ kind: "Failed", error: "NetworkError" });
    }
  };
 
  useEffect(() => {
    getData();
  }, []);
 
  return (
    <div className="container">
      <h1>Cat Fact</h1>
 
      {unwrapRemoteData(remoteData, {
        onLoading: () => {
          return <p>Loading...</p>;
        },
        onSuccess: (data) => {
          return <p>{data.fact}</p>;
        },
        onFailed: () => {
          return <p>Something went wrong...</p>;
        }
      })}
    </div>
  );
}

Notice how there is no need to include a "finally" statement which resets the isLoading state to false after our try-catch block because we can't possibly be in a "loading" and "success" state simultaneously. Also notice that, thanks to our unwrapRemoteData function, we can handle every state our component can be in within a containing div, and without having to use if/else statements.

Hopefully this helps you improve the way you model remote data in React.