React loading states and working effectively with data

Approaches to loading (and data fetching patterns) at the component level

React loading states and working effectively with data

Introducing loading states

In my app so far, I have implemented error boundaries for handling error states in React components, helping to provide a fallback if there is an issue during data fetching and rendering:

In the case above, a request has failed - but instead of crashing the app, a fallback card element is provided, providing a better user experience.

Another thing to consider when presenting visual user feedback is loading states. These can be especially useful especially useful when rendering a large number of components or making asynchronous requests that may take some time. Loading states allow you to render a placeholder, indicating that a component is expected to load.

The way you handle both loading and error states is closely tied to your app’s data-fetching patterns.

I’m going to focus on loading states in this context, progressing from familiar React methods to more advanced implementations using the React <Suspense> element. By the end of this article, I hope to show you the advantages of this approach while explaining how it relates to asynchronous calls with Promises. Thanks go to Tapas Adhikary, who has a brilliant video covering these data fetching patterns - highly recommended!

Handling loading states

Much like error handling, if we handle our loading state correctly, we should be able to display a fallback element while our elements are loading. Let’s look at a few different approaches.

Conditional rendering / fetch on render

One common way to handle both loading and error states, which you may already be familiar with, is through conditional rendering. This is a typical pattern used within React components that depend on fetching some data:

export default function Card() {
    const [data, setData] = useState([]);
    const [isLoading, setIsLoading] = useState(true);
    useEffect(() => {
        async function fetchData() {
          setIsLoading(true);
          const response = await fetch("https://apiexample.com/index");
          const fetchedData = await response.json();
          setData(fetchedData);
          setIsLoading(false);
        }
    fetchData();
  }, [])

    if (isLoading) {
        return (
            <div>Loading card contents...</div>
        )
    }
    return (
        <>
            <h2>Card header</h2>
            <DataList data={data} />
        </>
    )
}

In this example, data fetching is handled within the component with the useEffect() hook, which modifies the state of isLoading as the data is fetched.

The conditional block below this renders a fallback if we are still in a loading state, otherwise the expected element is returned.

While this approach is simple and works fine in small cases, there are some drawbacks.

This pattern, often called ‘fetch on render’, is not very optimised for complex applications, limiting the scalability of your app from the offset. This can especially become an issue as you introduce more data fetch requests and more elements to the component tree.

For example, if a rendered component has a child component that makes its own network call, that call can only be made once the parent has completed rendering. This results in what’s called a network waterfall - a sequence of blocking requests that can slow down your app.

'Kyoto Garden' waterfall by mikeanywhere on Unsplash

You also need to manually track the loading state. In every element that uses this approach, you’ll need to handle the loading state explicitly for every data request. There’s a more elegant solution for this, which we’ll discuss shortly (spoiler: it involves React Suspense).

Fetch then render

Another approach to bypass the "waterfall" effect is to run all data fetching outside the component, before rendering it. This way, the component already has the data it needs when it renders. You might call this a "fetch then render" approach.

Here’s an example:

async function fetchSomeData() {
  const response = await fetch("https://apiexample.com/index");
  const data = await response.json();
  return data;
}

async function fetchOtherData() {
  const response = await fetch("https://apiexample.com/other");
  const otherData = await response.json();
  return otherData;
}

async function fetchAllData() {
  const someData = await fetchSomeData();
  const otherData = await fetchOtherData();
  const allData = [someData, otherData];
  return allData;
}

const allData = fetchAllData();

export default function Card() {
  const [someData, setSomeData] = useState([]);
  const [otherData, setOtherData] = useState([]);
  const [isLoading, setIsloading] = useState(true);
  useEffect(() => {
    async function fetchData() {
      setIsLoading(true);
      const data = await allData;
      setSomeData(data[0]);
      setOtherData(data[1]);
      setIsLoading(false);
    }
    fetchData();
  }, [])

  if (isLoading) {
    return (
      <div>Loading data...</div>
    )
  }
  return (
    <>
      <h2>Card header</h2>
      <SomeData data={someData} />
      <OtherData data={otherData} />
    </>
  )
}

This approach allows for making multiple requests concurrently - an improvement over blocking requests. However, you still need to wait for all requests to complete before rendering the component, which ties rendering speed to the slowest network call.

We’re also subject to most of the same limitations as the previous approach:

  • We have to manually maintain a loading state and the state of our async calls ourselves;

  • Unoptimised rendering - Rendering is still tied to state updates after data fetching, making your component dependent on side effects to trigger re-renders, rather than being a pure component.

Introducing Suspense / render while fetching

Now let’s explore a more advanced approach: the "render while fetching" pattern, which addresses the shortcomings of the previous methods.

The goal here is to avoid sequential data fetching and start rendering components as soon as their data becomes available. This can be achieved using the <Suspense> element, which is native to React.

To handle data loading in a declarative way, we can give each data-dependent component its own loading boundary by wrapping it in a <Suspense> element:

import { Suspense } from "react";
import DataList from "./DataList";
import OtherData from "./OtherData";

export default function Card() {
  return (
    <>
      <h2>Some data</h2>
      <Suspense fallback={<p>Loading data...</p>}>
        <DataList />
      </Suspense>
      <Suspense fallback={<p>Loading other data...</p>}>
        <OtherData />
      </Suspense>
    </>
  )
}

In this method, we’ve split up the data loading, allowing each <Suspense>-wrapped component to load its data and render independently.

The fallback prop allows us to pass in some inline elements, or a component of our choice.

How Suspense Works

Suspense tracks the loading state based on a Promise. While a Promise is pending (i.e., while data is being fetched), the component is "suspended" and the fallback element is rendered until the Promise is resolved.

Here’s an example of a component handling its own data loading without useEffect() or useState() hooks:


// Datalist.tsx
async function fetchSomeData() {
  const response = await fetch("https://apiexample.com/index");
  const data = await response.json();
  return data;
}

export default function DataList() {
  const data = fetchSomeData();
  return(
    <ul>
      {
        data.map((item) => (
          <li key={item.key}>{item.name}</li>
        ))
      }
    </ul>
  )
}

(Note: there are many more ways to approach data fetching, each with their own boons - e.g. you could use Axios, Next.js or React Query, but in keeping with the previous examples I’ve opted for a simple fetch method).

React Suspense isn’t strictly required to make this pattern work, but it does greatly simplify how each component handles its unresolved requests (and therefore its loading state). To summarise some key advantages:

  • It simplifies loading state management by centralizing it outside of the component. This ‘higher level’ abstraction means we can follow a more declarative approach, leaving the managing of state updates to React, and allowing for greater reusability across components. You don’t need to manually track loading state in every component.

  • Much like error boundaries, it gives us more control over where this state is handled at different levels in the component tree - for example, you can show a loading indicator for an entire screen, or for smaller UI elements, without needing to manage each component’s state individually.

Suspense boundaries with error boundaries

Suspense boundaries also work well with error boundaries, which I’ve covered elsewhere. Typically you would wrap your suspense boundary (which assumes the Promise will eventually resolve successfully) with an error boundary (which handles cases where the Promise rejects):

export default function Card() {
  return (
    <>
        <h2>Some data</h2>
        <ErrorBoundary fallback={<p>Error loading data!</p>}>
          <Suspense fallback={<p>Loading data...</p>}>
            <DataList />
          </Suspense>
          <Suspense fallback={<p>Loading other data...</p>}>
            <OtherData />
          </Suspense>
        </ErrorBoundary>
    </>
  )
}

On its own, Suspense therefore won't handle cases where the Promise rejects - this is where the error boundary comes in, which allows us to catch and handle rejected Promises without crashing the application.

‘Skeleton’ components

It’s worth briefly mentioning that the approach we’ve focused on here works well with a fallback UI element that you’ve probably seen when loading many an application - the ‘skeleton’.

Skeleton components mimic the structure of the content being loaded, providing visual feedback that data is being fetched. This enhances the user experience (UX) by keeping the page’s structure intact and making it feel more responsive.

Here’s a skeleton fallback card component I implemented with Chakra UI:

This works because the card is wrapped in its own <Suspense> element.

To test this out, you can:

  • Use React Dev Tools, find the component and select ‘Suspend the selected component.’

  • Also in your browser’s dev tools, try throttling browser performance using the Network tab, and reloading the page. If the request takes some time, you should briefly see the loading state.

You can enhance this behaviour using lazy loading, for a more responsive experience - but I’ll come to this in another article as it’s a topic in its own right!

Summary

What I hope to have shown here is not only how to use React Suspense, but to help understand why you are implementing this as a feature, and where it can be most effective with specific data fetching patterns.

Using React Suspense greatly improves how you handle loading states, especially in applications with multiple asynchronous data-fetching components. By adopting a more declarative approach, you can optimize both your user experience and your codebase, reducing the complexity of manually managing loading states in each component.

Seeing things in this abstracted way will also naturally help prepare for using meta-frameworks like Next.js, which take a higher-level approach to this kind of feature.

Next I’ll be moving away from component-level loading states to focus on route-level loading states, working specifically with React Router.