Suspense, or how to stop writing isLoading

Suspense and error boundaries in React, then the Next.js loading.tsx and error.tsx files that set them up for you.

Context

Handling loading states has always been a pain in React.

It’s part of a bigger problem: you quickly end up managing promises (Promise).

Historically, we started with useEffect:

const MyComponent = () => {
  const [isLoading, setIsLoading] = useState(false); // 👈 state
  const [data, setData] = useState(null);

  useEffect(() => {
    setIsLoading(true); // 👈 loading
    fetch(url)
      .then((response) => response.json())
      .then(setData)
      .finally(() => setIsLoading(false)); // 👈 done loading
  }, []);

  if (isLoading) {
    return <Loader />; // 👈 dedicated UI
  }

  return <Content data={data} />;
};

Libraries showed up to help, like TanStack Query:

const MyComponent = () => {
  const { data, isLoading } = useQuery(query); // 👈 🙏 `useQuery`

  if (isLoading) {
    // 👈 but this is still here, and it’s annoying
    return <Loader />;
  }

  return <Content data={data} />;
};

The problem

The code above shows it well: handling every loading state is complex.

Error handling 💥 is no better 😭

Components grow fast, each one with its own state handling.

So the React team came up with a new paradigm: suspense (Suspense).

ℹ️ I’m simplifying: the real paradigm shift is “concurrent rendering”. We’ll get there below.

The fix: Suspense

Suspense lets React know that something is loading.

In practice, libraries handle that part. With TanStack Query, you call useSuspenseQuery instead of useQuery.

React now knows about the loading state.

But what does it do with it? 🤔

It walks up the render tree to the closest Suspense component. That component defines a fallback, shown while its children load.

Here’s how it looks:

const MyComponent = () => {
  const { data } = useSuspenseQuery(query); // 👈 only the happy path 🎉

  return <Content data={data} />;
};

const App = () => {
  // 👇 the fallback shows while loading 🔃
  return (
    <Suspense fallback={<Loader />}>
      <MyComponent />
    </Suspense>
  );
};

Neat, right? 😁

Bonus: data is never undefined anymore, since the component only renders once the data is there.

ℹ️ With server components (a topic for another day), Suspense also lets you stream the rendering of its children.

Suspense ❤️ Next.js

Now let’s talk about Next.js and its App Router, built entirely around concurrent rendering.

Take this tree:

app
└── page.tsx

For now, there’s just a page.

You probably know you can define a layout, which wraps the page:

app
├── layout.tsx
└── page.tsx

Which is the same as writing:

<Layout>
  <Page />
</Layout>

But did you know you can also add a loading.tsx file?

app
├── layout.tsx
├── loading.tsx
└── page.tsx

Next then sets up the Suspense for you. It’s the same as writing:

<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>

But Guillaume, loading isn’t the only state, there are errors too 🤔

Good point, Jean-Michel.

Time to correct the shortcut I took earlier.

Concurrent rendering

I described Suspense as a rendering mode. That’s not accurate: the rendering mode is concurrent rendering, which came with React 18.

In short, it lets React render the critical parts of the tree first and defer the rest.

To split the tree, React uses boundaries. Suspense is a loading boundary.

And isn’t there another kind of boundary we already know?

Yes: error boundaries!

Same idea, but for errors. The catch is that you still have to write them as a class, and it isn’t pretty:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render shows the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    logErrorToMyService(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}

Good news: nobody writes this by hand. The react-error-boundary library ships a ready-made one.

Our render can now look like this:

const MyComponent = () => {
  const { data } = useSuspenseQuery(query); // 👈 still only the happy path 🎉

  return <Content data={data} />;
};

const App = () => {
  return (
    <ErrorBoundary fallback={<Boom />}>
      <Suspense fallback={<Loader />}>
        <MyComponent />
      </Suspense>
    </ErrorBoundary>
  );
};

And with Next, it’s even simpler: add an error.tsx file.

app
├── error.tsx
├── layout.tsx
├── loading.tsx
└── page.tsx

Next then sets up both boundaries for you:

<Layout>
  <ErrorBoundary fallback={<Error />}>
    <Suspense fallback={<Loading />}>
      <Page />
    </Suspense>
  </ErrorBoundary>
</Layout>

Watch out: error.tsx must be a client component ('use client' on the first line), because an error boundary lives in the browser.

That’s it, you now know everything about Suspense ❤️

Questions? Find me on LinkedIn.

Resources