A Quick Technical Primer

Before we explore the broader implications, it’s worth covering the basics so everyone is on the same page. Many excellent resources already explain the inner workings of Suspense in great detail, so here we’ll only touch on the core concepts.

Suspense was first unveiled by Dan Abramov at JSConf Iceland, positioned as a major improvement to how developers handle asynchronous data fetching in React applications. For anyone building dynamic web apps, this is a significant milestone, as managing async state has long been a source of boilerplate and complexity.

The framework is designed to shift how we approach loading states. Instead of tying spinners and placeholders directly to a data-fetching component, Suspense treats loading as a UI-level concern. This means the display of a loading indicator becomes contextual, placed where it makes the most sense for the user experience, rather than being dictated by where a request originates.

Suspense doesn’t care why (or how many times) you suspend, so a single spinner can work for a combination of code splitting, data fetching, image loading, etc. Whatever the tree below needs. And if it’s fast enough, then no spinner at all!

— Dan Abramov (@dan_abramov) June 14, 2018

This mechanism is not limited to API calls; it extends to any asynchronous operation, such as code splitting or asset loading. The combination of React.lazy and the Suspense component has already landed in the latest stable release of React, simplifying dynamic imports by removing the need for manual loading-state management. A fully-featured Suspense, including built-in data fetching support, is scheduled for later this year, though early experimentation is possible using current alpha builds.

The underlying idea is straightforward: a component can pause its render, for instance if it relies on data that hasn’t loaded yet. Once the required information is available, React retries the render.

Mechanically, this is accomplished using Promises. A component can throw a Promise during its render lifecycle, or from any function invoked during that render, such as the static getDerivedStateFromProps. React intercepts this thrown Promise and locates the nearest Suspense component higher in the tree, which acts as a boundary. The Suspense component accepts a fallback element, shown whenever any descendant in its subtree is suspended, regardless of the cause.

React monitors the thrown Promise. Upon resolution, it reattempts rendering the suspended component, assuming the data is now present. A cache typically stores the resolved data; on each render, the cache checks if the data exists (and if so, reads it synchronously) or triggers a fetch and throws the Promise for React to handle. This pattern is applicable to any async action expressible via Promises, with code splitting being a prominent and widely used example.

The architecture closely resembles error boundaries, introduced in React 16, which catch uncaught exceptions anywhere in the tree through components implementing componentDidCatch. Similarly, Suspense captures thrown Promises from its children. However, unlike error boundaries, no custom component is needed—the Suspense component itself serves as the boundary, providing a place to define the fallback UI and other configuration options we’ll discuss later.

Why React Suspense Will Be a Game Changer — figure 1

This diagram is a simplified illustration of how Suspense modifies rendering. An interactive example is available at this CodeSandbox demo.

This design fundamentally simplifies how we model loading states, aligning developer thinking more closely with UX and information design principles.

Designers rarely consider data sources; they work with logical groups and information hierarchies. Users, similarly, are indifferent to where data lives. A cluttered interface with numerous individually triggered spinners—many flickering for mere milliseconds—causes the page layout to shift unpredictably while requests complete, which is a poor experience for everyone.

What Makes This Such a Game Changer?

The Core Issue

To appreciate the significance, let's examine how we typically handle data fetching in current applications.

The most straightforward method involves storing all required information in local state, which might appear something like:

class DynamicData extends Component {
  state = {
    loading: true,
    error: null,
    data: null
  };

  componentDidMount () {
    fetchData(this.props.id)
      .then((data) => {
        this.setState({
          loading: false,
          data
        });
      })
      .catch((error) => {
        this.setState({
          loading: false,
          error: error.message
        });
      });
  }

  componentDidUpdate (prevProps) {
    if (this.props.id !== prevProps.id) {
      this.setState({ loading: true }, () => {
        fetchData(this.props.id)
          .then((data) => {
            this.setState({
              loading: false,
              data
            });
          })
          .catch((error) => {
            this.setState({
              loading: false,
              error: error.message
            });
          });
      });
    }
  }

  render () {
    const { loading, error, data } = this.state;
    return loading ? (
      <p>Loading...</p>
    ) : error ? (
      <p>Error: {error}</p>
    ) : (
      <p>Data loaded ?</p>
    );
  }
}

An example of traditional async data flow in React using local state

Quite verbose, isn't it?

We initiate the fetch on mount and store it in local state. We also manage error and loading conditions through local state. Does this seem familiar? Even if you rely on some form of abstraction rather than raw state, you probably still have numerous loading ternaries scattered throughout your codebase.

I wouldn't label this approach as inherently flawed (it works for simple scenarios, plus we can easily refine it, such as extracting the fetch logic into a separate function), but it struggles to scale and the developer experience leaves much to be desired. To be precise, I identify the following problems:

  1. ? Messy ternaries → poor DX
    Loading and error states are handled through ternaries in the render, making the code unnecessarily complex. We're not describing a single render function; we're describing three.
  2. ? Repetitive code → poor DX
    Managing all this state requires substantial boilerplate: initiating the fetch on mount, updating loading state, storing data on success or capturing the error on failure. This pattern must be repeated for every component that depends on external data.
  3. ? Restricted data and loading state → poor DX & UX
    State is handled and stored within the component, resulting in countless spinners throughout the app, plus redundant API calls to the same endpoints when multiple components need identical data. This ties back to my earlier point—the notion of tying loading states to data sources doesn't seem right. With this approach, loading state is bound to the data fetch and its component, restricting us to handling it within that component (or finding workarounds) rather than managing it at a broader application level.
  4. ? Re-fetching data → poor DX
    Handling changes to ids that require re-fetching is cumbersome. We need to perform the initial fetch in componentDidMount and additionally verify id changes in componentDidUpdate.
  5. ? Flashing spinners → poor UX
    When the user's connection is fast, showing a loading spinner for just a few milliseconds is worse than showing nothing, making the app feel sluggish and slower. Perceived performance is crucial.

Notice the trend? It may not surprise many, but it became clear to me how closely developer and user experience are intertwined.

So, now that we've identified these issues, how do we resolve them?

Leveraging Context

For a long time, Redux was the preferred solution for many of these challenges. With the “new” Context API in React 16, another excellent tool emerged to define and expose data globally while making it easily accessible in deeply nested component trees. For simplicity, we'll stick with this approach here.

First, we can extract all the information previously stored in local state into a context provider, enabling us to share it across components. We can also expose a method to fetch data through this provider, so components only need to trigger this method and read the loaded information via a context consumer. The recently released in React 16.6 [contextType](https://reactjs.org/docs/context.html#classcontexttype) makes this both more elegant and less verbose.

The provider also acts as a cache, preventing us from hitting the same endpoint repeatedly if the data is already present or loading, perhaps triggered by another component.

const DataContext = React.createContext();

class DataContextProvider extends Component {
  // We want to be able to store multiple sources in the provider,
  // so we store an object with unique keys for each data set +
  // loading state
  state = {
    data: {},
    fetch: this.fetch.bind(this)
  };

  fetch (key) {
    if (this.state[key] && (this.state[key].data || this.state[key].loading)) {
      // Data is either already loaded or loading, so no need to fetch!
      return;
    }

    this.setState(
      {
        [key]: {
          loading: true,
          error: null,
          data: null
        }
      },
      () => {
        fetchData(key)
          .then((data) => {
            this.setState({
              [key]: {
                loading: false,
                data
              }
            });
          })
          .catch((e) => {
            this.setState({
              [key]: {
                loading: false,
                error: e.message
              }
            });
          });
      }
    );
  }

  render () {
    return <DataContext.Provider value={this.state} {...this.props} />;
  }
}

class DynamicData extends Component {
  static contextType = DataContext;

  componentDidMount () {
    this.context.fetch(this.props.id);
  }

  componentDidUpdate (prevProps) {
    if (this.props.id !== prevProps.id) {
      this.context.fetch(this.props.id);
    }
  }

  render () {
    const { id } = this.props;
    const { data } = this.context;

    const idData = data[id];

    return idData.loading ? (
      <p>Loading...</p>
    ) : idData.error ? (
      <p>Error: {idData.error}</p>
    ) : (
      <p>Data loaded ?</p>
    );
  }
}

An example using the Context API

We might even try to eliminate the ternary within the component. Suppose we want the loading spinner higher up the tree, covering more than just this component. Since the loading state now lives in the context, we can simply access it wherever needed and display a loading spinner there, correct?

This remains problematic because the AsyncData component must render to trigger the data fetch in the first place. Sure, we could hoist the fetch higher up the tree instead of triggering it in the component, but that merely relocates the problem rather than solving it. It also harms code readability and maintainability—suddenly AsyncData depends on some other component to load its data. This dependency is neither obvious nor good. Ideally, components should work as independently as possible, allowing them to be placed anywhere without relying on specific components elsewhere in the tree.

At least we now have all data and loading states centralized, which is an improvement. And since we can position the provider anywhere, we can access this information and functionality from wherever needed, meaning other components can reuse it (no more redundant code) and take advantage of already loaded data, eliminating unnecessary API calls.

To put this into perspective, let's revisit our original issues:

  1. ? Messy ternaries
    Nothing changed here; all we can do is move the ternaries elsewhere, which doesn't address the DX problem.
  2. ? Repetitive code
    We've removed much of the boilerplate that was previously required. We now only need to trigger the fetch and read the data and loading state from the context, leading to significantly less repetitive code, which improves readability and maintainability.
  3. ? Restricted data and loading state
    We now have a global state accessible anywhere in the application. This is a substantial improvement, but we haven't solved all issues: loading states are still tied to the data source, and even if we work around these dependencies, showing a loading state based on multiple components fetching their data still requires explicit knowledge of which sources and manual checks on individual loading states.
  4. ? Re-fetching data
    Nothing changed here…
  5. ? Flashing spinners
    This remains unresolved…

I think we can all agree this is still a solid improvement, but some problems persist.

Enter Suspense

How do we improve with Suspense?

For starters, we can eliminate the context; data handling and caching will be managed by a cache provider, which could be anything. Context, local storage, a window object (even Redux if you insist), you name it. This provider simply stores the information we request. On each request, it first checks whether the information is already cached. If so, it returns it. If not, it fetches the data and throws the Promise. Before resolving the Promise, it stores the loaded information in its cache, so that when React triggers the re-render, everything is available. That's it. Obviously, more complex use cases require more sophisticated handling, considering cache invalidation and SSR, but this captures the general idea.

This caching functionality is also why Suspense for data fetching hasn't reached stable React yet. If you're curious, the experimental package called [react-cache](https://github.com/facebook/react/tree/master/packages/react-cache) is available for experimentation. But be warned: in its early stages, the API will definitely change, and many common use cases aren't yet supported.

Note: Suspense in 16.6 is intended for code splitting. The data fetching aspect isn't ready yet! Don't use any “cache” package until we document and release it as stable. ? Unless you're just experimenting.

— Dan Abramov (@dan_abramov) October 25, 2018

Beyond that, we can also eliminate all loading state ternaries. Even more, instead of fetching on mount and update, Suspense works by conditionally fetching during render, suspending it if the data isn't already in the cache. This might seem like an anti-pattern initially (after all, we've been told not to do this), but it makes sense when you consider that if the data is in the cache, the provider simply returns it and the render proceeds normally.

import createResource from './magical-cache-provider';
const dataResource = createResource((id) => fetchData(id));

class DynamicData extends Component {
  render () {
    const data = dataResource.read(this.props.id);
    return <p>Data loaded ?</p>;
  }
}

The same example using Suspense for data fetching.

Finally, we can position the boundaries and define the fallback component to render while data is fetching. Where? Anywhere we want. As explained, these boundaries catch any thrown Promises bubbling up from components below them in the tree.

class App extends Component {
  render () {
    return (
      <Suspense fallback={<p>Loading...</p>}>
        <DeepNesting>
          <ThereMightBeSeveralAsyncComponentsHere />
        </DeepNesting>
      </Suspense>
    );
  }
}

// We can also be very specific with multiple boundaries
// They don't need to know what components might be suspending
// their render or why, they just catch whatever bubbles up and
// handle it as intended
class App extends Component {
  render () {
    return (
      <Suspense fallback={<p>Loading...</p>}>
        <DeepNesting>
          <MaybeSomeAsycComponent />
          <Suspense fallback={<p>Loading content...</p>}>
            <ThereMightBeSeveralAsyncComponentsHere />
          </Suspense>
          <Suspense fallback={<p>Loading footer...</p>}>
            <DeeplyNestedFooterTree />
          </Suspense>
        </DeepNesting>
      </Suspense>
    );
  }
}

I think this undeniably makes the code much cleaner, and the logical data flow is now easily readable from top to bottom. But how does it address our problems?

  1. ❤️ Messy ternaries
    Gone. The fallback rendering is now managed by the boundary, making the code easier to follow and more intuitive. Loading state has become a UI concern, decoupled from the actual data fetching.
  2. ❤️ Repetitive code
    We improved this even further by eliminating the need for life cycle methods to trigger fetches. Also, with future libraries acting as cache providers, switching between storage solutions will be as simple as swapping them out.
  3. ❤️ Restricted data and loading state
    Solved. We now have clear boundaries for loading states that don't care about the source or reason for loading. Whenever any component within the boundary is suspended, the loading state renders, period.
  4. ❤️ Re-fetching data
    Since we can pass the source directly in the render method, changes to props that should trigger re-fetching happen automatically, without any extra work. The cache provider handles it.
  5. ? Flashing spinners
    Well, this is still a problem ?

These are massive improvements, but we still have one remaining issue… however, now that we're using Suspense, React has another trick to help with that too.

Concurrent mode to finish the job

Concurrent mode, formerly known as Async React, is another upcoming feature that lets React work on multiple tasks simultaneously, switching between them based on defined priorities, effectively enabling multi-tasking. Andrew Clark gave a fantastic talk on it at last ReactConf, including a great demo of its user impact. I won't delve into all details here, since this genuinely deserves a post of its own.

However, by adding concurrent mode to our app, Suspense gains a new capability controllable via a prop on the Suspense component. If we pass in a maxDuration, the boundary delays showing the loading spinner for that duration, preventing spinners from flashing unnecessarily. It also ensures the spinner persists for a minimum duration, addressing the same issue to keep the user experience as smooth as possible.

// Instead of this...
ReactDOM.render(<App />, document.getElementById('root'));

// ...we do this
ReactDOM.createRoot(document.getElementById(‘root’)).render(<App />);

Enabling concurrent mode only requires changing one line. No additional logic needed at all. Mind blown ? — Note that this is still in alpha and not production-ready yet!

To clarify, this won't make data load faster, but users will perceive it as such, dramatically improving the experience.

Also, concurrent mode isn't a requirement for Suspense. As we saw, the core functionality works perfectly fine without it and already solves many problems. Concurrent mode is the icing on the cake—not essential, but fantastic when present.

The takeaway — or a TL;DR if you prefer

Seeing Suspense, it feels like we had the whole concept of handling loading states backwards until now. They were always tightly coupled to the data source, which usually ties to a specific data set or component.

Instead, Suspense lets us treat loading states as a UI concern. It becomes straightforward to define areas in your app that handle loading states, while simultaneously cleaning up verbose and repetitive code—a long-standing burden we've carried for years.

I also want to give a huge shout out to the React team at this point, not just for their work on the functionality itself, but even more for letting us developers explore it in early stages, allowing healthy discussions about API design and enabling the ecosystem to prepare, so everything will be in place once Suspense hits stable release later this year. Thanks! ?


PS: I gave a talk about all of this at the ReactBris meetup. You can find the slides and a small demo application seeing Suspense and Concurrent Mode in action here: https://github.com/julianburr/talk-suspense-game-changer