In JS we can fetch data asyncronously with callbacks, promises, and async/await. For handling errors we could use exceptions or lean on the compiler and use a result type.

We can integrate async data into React components with hooks, so to fetch a user we might have a hook called useUser:

function UserPage({ userId }: { userId: string }) {
  const user = useUser(userId)
  return <div>{user.email}</div>
}

But what about the error and loading states?

Loading and Error States

The data doesn’t appear in component instantly, we need to wait for the server and sometimes the server fails.

So we need to surface some statuses in our API:

  • initial – fetch hasn’t started (sometimes combined with loading)
  • loading – fetch in progress
  • refetching – sucessful fetch before, fetching again (sometimes combined with success)
  • success – fetch complete with data
  • error – fetch failed

Tagged Unions aka Sum Types aka Discrimiated Union Types

Aka making the impossible states impossible

type Result<T, E> =
  | { type: "initial" }
  | { type: "loading" }
  | { type: "refetching"; data: T }
  | { type: "success"; data: T }
  | { type: "error"; err: E }

with some checks against the type field we can ensure we only render the data when all the other cases have been handled.

function UserPage({ userId }: { userId: string }) {
  const result = useUser(userId)
  if (result.type === "initial" || result.type === "loading") {
    return <div>Loading...</div>
  }
  if (result.type === "failure") {
    return <div>Failure...</div>
  }
  return <div>name: {result.data.name}</div>
}

This allows the compiler to check we’ve handled all the variants via narrowing.

Bunch of Bools

Instead of having a singular tag to check against, some libraries opt for using individual boolean fields for each variant.

SWR

function UserPage({ userId }: { userId: string }) {
  const { data, error } = useUser(userId)
  if (!data) {
    return <div>Loading...</div>
  }
  if (error) {
    return <div>Failure: {error}</div>
  }
  return <div>name: {data.name}</div>
}

One issue is SWR’s typing is isn’t great:

interface SWRResponse<Data, Error> {
  data?: Data
  error?: Error
  mutate: KeyedMutator<Data>
  isValidating: boolean
}

Presumably we can’t have both data and error, but the types allow it.

React Query

Similar to SWR’s API, except it has proper tagged unions in the return type.

type QueryObserverResult<TData = unknown, TError = unknown> =
  | QueryObserverIdleResult<TData, TError>
  | QueryObserverLoadingErrorResult<TData, TError>
  | QueryObserverLoadingResult<TData, TError>
  | QueryObserverRefetchErrorResult<TData, TError>
  | QueryObserverSuccessResult<TData, TError>

And as of TypeScript 4.4, we can even destructure the result and have narrowing work for the variants (initial, loading, success, refetching, error).

function UserPage({ userId }: { userId: string }) {
  const { data, status, error } = useQuery(USER_QUERY)
  if (status === "loading" || status === "idle") {
    return <div>loading...</div>
  }
  if (status === "error") {
    return <div>error: {error}</div>
  }
  return <div>name: {data.name}</div>
}

While we’re using the status field for refinement, React Query also returns boolean flags for each of the variants that we could discriminate off:

function UserPage({ userId }: { userId: string }) {
  const { data, isLoading, isIdle, isError, error } = useQuery(USER_QUERY)
  if (isLoading || isIdle) {
    return <div>loading...</div>
  }
  if (isError) {
    return <div>error: {error}</div>
  }
  return <div>name: {data.name}</div>
}

Apollo

Apollo has a similar API to SWR and React Query, but the types aren’t great:

interface QueryResult<TData = any, TVariables = OperationVariables>
  extends ObservableQueryFields<TData, TVariables> {
  client: ApolloClient<any>
  data: TData | undefined
  previousData?: TData
  error?: ApolloError
  loading: boolean
  networkStatus: NetworkStatus
  called: true
}

We have the same issue as SWR where data and error are available at the same time.

function UserPage({ userId }: { userId: string }) {
  const { loading, error, data } = useQuery(USER_QUERY)
  if (loading || data == null) {
    return <div>loading...</div>
  }
  if (error) {
    return <div>error {error}</div>
  }
  return <div>name: {data.name}</div>
}

React Suspense

While limited in support and experimental, Suspense has a unique API which surfaces error and loading states by the component suspending (throwing a promise that bubbles up to React.Suspense and error boundary components.).

The React docs provide a pretty thorough example and the Relay docs provide more info on error and loading states as well as prefetching data.

Conclusion

I think React Query has the best API and TypeScript definitions of the compared libraries and I hope SWR and Apollo take some inspiration.