Iva Kop I am a self-taught software developer passionate about frontend development and architecture.

UI best practices for loading, error, and empty states in React

7 min read 2103

React Logo Over a Wavy Gray Background

Building thought-out and creative UIs is difficult. Even excellent UX/UI designs can never tell the full story of a web app.

Because they are only a static representation of something inherently dynamic, it’s up to developers to make designs come to life, which, of course, means taking into account all possible states.

In this article, we’ll cover the best practices to use in client-side rendered React apps when handling loading, error, and empty state.

React components

Imagine we want to recreate the table from Wikipedia’s list of cat breeds with React and Chakra UI in the context of the single page application (SPA) we are building.

The image below is the design we aim to achieve. Simple enough, right?

Cats Table

To begin with, let’s extract our cat breeds data into a separate file, data.json:

[
  {
    "name": "Abyssinian",
    "origin": "Unspecified, but somewhere in Afro-Asia likely Ethiopia",
    "type": "Natural",
    "bodyType": "Semi-foreign",
    "coat": "Short",
    "pattern": "Agouti",
    "imgUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9b/Gustav_chocolate.jpg/200px-Gustav_chocolate.jpg"
  },
  {
    "name": "Aegean",
    "origin": "Greece",
    "type": "Natural",
    "bodyType": "Moderate",
    "coat": "Semi-long",
    "pattern": "Multi-color",
    "imgUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Aegean_cat.jpg/200px-Aegean_cat.jpg"
  },
  ...
]

We can now create a TableComponent that relies on this data:

import { Table, Thead, Tbody, Tr, Th, Td } from "@chakra-ui/react";
import data from "../public/data.json";

const TableComponent = () => {
  return (
      <Table colorScheme="blue" overflow="none">
        <Thead>
          <Tr>
            <Th></Th>
            <Th>Name</Th>
            <Th>Origin</Th>
            <Th>Type</Th>
            <Th>Body type</Th>
            <Th>Coat</Th>
            <Th>Pattern</Th>
          </Tr>
        </Thead>
        <Tbody>
          {data.map((cat) => (
            <Tr key={cat.name}>
              <Td p="2">
                <Box
                  bgImage={cat.imgUrl}
                  w="150px"
                  h="150px"
                  backgroundSize="cover"
                />
              </Td>
              <Td>{cat.name}</Td>
              <Td>{cat.origin}</Td>
              <Td>{cat.type}</Td>
              <Td>{cat.bodyType}</Td>
              <Td>{cat.coat}</Td>
              <Td>{cat.pattern}</Td>
            </Tr>
          ))}
        </Tbody>
      </Table>
  );
};

Done! It looks exactly as expected.

But this implementation is also very naive! The data for our cats table is simply hard-coded. In a real-world React app, it will most likely come from a server. This, in turn, means that it will not be immediately available when the user opens the page. We have to wait for it.

Loading state

Now we come to the first state that is not explicitly included in the image above, the loading state. When we request the data, we need to indicate to our users that something is loading while we wait for the server response. Otherwise, they will just see a blank screen.

So, let’s add a Spinner to our TableComponent:

import React, { useState } from "react";
import {
  ...
  Spinner
} from "@chakra-ui/react";
import useCats from "./useCats";

const TableComponent = () => {
  // Custom data-fetching hook to handle the server request
  const { data, isLoading } = useCats();

  if (isLoading) {
    return <Spinner />;
  }

  return (
    <Table>
      ...
    </Table>
  );
};

This is starting to look much more realistic already. But here is a thought: do we need to show a spinner every time we are fetching the table data?

What if we want to navigate between pages, for example? It would be disruptive for the table to disappear and a spinner to appear every time the user goes to a new page. The same applies if we implement a search.

Or, what if we have already cached the table data in our app, but we still want to re-fetch it in the background to ensure it’s not stale? Once again, while we re-fetch, it will be much better for the user to be able to see the already available data instead of a spinner.

For our UI to truly feel polished, we need to indicate to users when something is happening in the background. It looks like we need a second loading state, one that allows us to also display our TableComponent.



Let’s add it!

import React, { useState } from "react";
import {
  ...
  Spinner,
  Progress,
} from "@chakra-ui/react";
import useCats from "./useCats";

const TableComponent = () => {
  const { data, isLoading } = useCats();

  if (isLoading && !data) {
    return <Spinner />;
  }

  return (
    <>
      {isLoading && (
        <Progress />
      )}
      <Table>
        ...
      </Table>
    </>
  );
};

This is neat! We now show our Spinner only when there is no data to show the user. Otherwise, we display the much more subtle Progress on top of the table.

But what if there is no data at all?

Empty state in React

Because our data is coming from a server, we can’t know for sure if there will be data at all. We might receive an empty list. If this happens with our current implementation, the user will only see the Table headers, which will be pretty confusing.

We should add an empty state with an appropriate message to our component to account for this scenario:

import React, { useState } from "react";
import {
  ...
  Spinner,
  Progress,
  Text
} from "@chakra-ui/react";
import { SearchIcon } from "@chakra-ui/icons";
import useCats from "./useCats";

const EmptyState = () => {
  return (
    <Box>
      <SearchIcon />
      <Text>
        No cats found!
      </Text>
    </Box>
  );
};

const TableComponent = () => {
  const { data, isLoading } = useCats();

  if (isLoading && !data) {
    return <Spinner />;
  }

  if (data?.length === 0) {
    return <EmptyState />;
  }

  return (
    <>
      {isLoading && (
        <Progress />
      )}
        <Table>
          ...
        </Table>
    </>
  );
};

Perfect! We are almost done. There is just one last thing to consider.

Error state in React

Because we are relying on a server response for our data, it is important to consider a situation where our request fails. If this is the case, we need to show our user an appropriate error message.

Let’s do it:

import React, { useState } from "react";
import {
  ...
  Alert,
  AlertIcon,
  AlertTitle,
  AlertDescription
} from "@chakra-ui/react";
import useCats from "./useCats";
...
const ErrorState = () => {
  return (
    <Alert status="error">
      <AlertIcon />
      <AlertTitle mr={2}>An error occured!</AlertTitle>
      <AlertDescription>Please contact us for assistance.</AlertDescription>
    </Alert>
  );
};

const TableComponent = () => {
  const { data, isLoading, isError } = useCats();

  if (isError) {
    return <ErrorState />;
  }

  if (isLoading && !data) {
    return <Spinner />;
  }

  if (data?.length === 0) {
    return <EmptyState />;
  }

  return (
    <>
      {isLoading && (
        <Progress size="xs" isIndeterminate w="100%" position="fixed" top="0" />
      )}
      <Table colorScheme="blue" overflow="none">
        ...
      </Table>
    </>
  );
};

Our component now works perfectly. We have truly thought of every possible scenario. Curious about the code? Play with it yourself here.

Keep in mind that the above is a simplified example based on a single table component. In a real-world app, there might be dozens of pages with many tables and components that all need their own loading, error, and empty states.


More great articles from LogRocket:


So, you may be wondering: is there a way to implement our approach at scale?

Datafetching UIs at scale

In order to implement a scalable data-fetching solution for our client-side rendered React app, we first need to have the right tools for the job at our disposal. Here are some helpful tips.

First, use an established, battle-tested data-fetching library to handle data synchronization and caching. Creating something like this from scratch is both difficult and unnecessary.

There are a number of good options for this. My personal preference is React Query. Once we start using a data-fetching library, it becomes trivial to implement a custom React hook, similar to our useCats example, for every request we might need to make.

As shown in the example above, these hooks contain all of the information we need to implement each of our component data-fetching states.

Then, take advantage of a component library or, at the very least, build the loading, error, and empty states components with reusability in mind. In our example, we are using Chakra UI for the basic building blocks. But we also separated our ErrorState and EmptyState components from the main table, ensuring we can use them again in other places in our app.

Finally, UX/UI consistency across the application is really important for users. To enhance it even further, make sure to standardize the way data-fetching states are handled. This can be done by abstracting even further the conditional rendering logic we used above.

If, for some reason, abstraction is not a good option, make sure to at least strongly encourage standardization throughout the codebase through convention and/or linter rules.

React Suspense for data-fetching

Before we wrap up this overview, it is important to mention that a major React update is in the making — React Suspense for data fetching. This new feature will allow it to declaratively wait for anything asynchronous, including data. It will be, in many ways, a paradigm shift in our thinking about data in React as we switch from a “fetch-on-render” to a “render-as-you-fetch” approach (more information about this shift can be found here).

Let’s re-implement our component with Suspense to see how it works. First, we need to create a fake API for our data.

import data from "./data.json";

export function fetchData() {
  let catsPromise = fetchCats();
  return {
    cats: wrapPromise(catsPromise)
  };
}

function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(
    (r) => {
      status = "success";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    }
  );
  return {
    read() {
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    }
  };
}

function fetchCats() {
  console.log("fetch cats...");
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("fetched cats");
      resolve(data);
      // reject();
    }, 1100);
  });
}

Note: this is just an example implementation, not production-ready code!

Loading state with React Suspense

With the API in place, we can add our loading state to the Suspense fallback.

import React, { Suspense } from "react";
import { ChakraProvider } from "@chakra-ui/react";
import * as ReactDOM from "react-dom";
import Table from "./Table";
import LoadingState from "./LoadingState";

const rootElement = document.getElementById("root");
ReactDOM.createRoot(rootElement).render(
  <ChakraProvider>
    <Suspense fallback={<LoadingState />}>
      <Table />
    </Suspense>
  </ChakraProvider>
);

And change our component accordingly:

import React from "react";
import { Table, ... } from "@chakra-ui/react";
import { SearchIcon } from "@chakra-ui/icons";
import { fetchData } from "./fakeApi";
import EmptyState from "./EmptyState"

const data = fetchData();

const TableComponent = () => {
  const cats = data.cats.read();

  if (cats?.length === 0) {
    return <EmptyState />;
  }

  return (
      <Table colorScheme="blue" overflow="none">
        ...
      </Table>
  );
};
export default TableComponent;

Nice, we are now using Suspense for data fetching! Also, as we can see, the empty state implementation remains the same as before.

But what happened to our error handling?

Error state with React Suspense

Another interesting change that comes with using Suspense for data fetching is that we can handle fetching errors the same way we handle rendering errors. For this we will need to use — you guessed it — ErrorBoundary.

Let’s add it to our app:

import React, { Suspense } from "react";
import { ChakraProvider } from "@chakra-ui/react";
import * as ReactDOM from "react-dom";
import Table from "./Table";
import LoadingState from "./LoadingState";
import ErrorState from "./ErrorState";
import ErrorBoundary from "./ErrorBoundary";

const rootElement = document.getElementById("root");
ReactDOM.createRoot(rootElement).render(
  <ChakraProvider>
    <ErrorBoundary fallback={<ErrorState />}>
      <Suspense fallback={<LoadingState />}>
        <Table />
      </Suspense>
    </ErrorBoundary>
  </ChakraProvider>
);

Perfect, now we are able to display our error component just as we did before. Check out this Code Sandbox for a working example.

There are several advantages to using these anticipated new features. With their help, we can granularly control error and loading states in our app through the placement of the Suspense and ErrorBoundary components.

At the same time, data fetching and rendering logic remain decoupled (fetching kicks off before rendering). With Suspense, it is also possible to avoid race conditions that often plague more traditional approaches.

While React Suspense is not a data-fetching library by itself, there are steps being taken to integrate it into existing solutions.

These new React updates are very exciting and a lot of fun to play with! But it’s important to remember that it’s all still experimental and subject to change. For the most up-to-date information, you can follow the discussions in the React 18 Working Group repo.

If you found this article useful, follow me on Twitter and visit my blog for more tech content.

Happy coding! ✨

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Iva Kop I am a self-taught software developer passionate about frontend development and architecture.

One Reply to “UI best practices for loading, error, and empty states…”

  1. Hi,
    Thank you for this great article.

    1. How would you handle if cats data is never null, but is returned as an empty array? That makes handling progress more challenging?

    2. The suspense – looks interesting, there are some warnings about your implementation and react18. There is no progressBar?

    Cheers

Leave a Reply