30% offEnding soon

ReactJS Suspense: An Interview-Ready Guide

21 min read

React Suspense coordinates what React displays while supported content in part of a component tree is not ready. The interview skill is choosing a useful boundary, providing stable data inputs, and handling loading and failure as separate states.

On an initial render, React Suspense normally attempts to show the closest boundary's fallback while supported content is not ready, then reveals the content when it becomes ready. If that fallback suspends, a parent boundary handles it; during a Transition or deferred update, React may retain already revealed content.

What Is React Suspense?

A Suspense boundary waits when supported content is not ready. In stable React 19.2, activators include code loaded with lazy, cached Promises read with use, stylesheets rendered with a precedence prop, and boundary HTML arriving during streaming server rendering. React searches upward from suspended components for the nearest <Suspense> boundary (React Suspense reference).

What can make rendering wait?lazy codecached Promiseprecedence CSSstreamed HTMLSuspensewaiting childfallback UIuseEffect fetchnot a Suspense source
Supported sources can pause rendering; an Effect request cannot activate the boundary by itself.

A Suspense boundary has two important props:

  • children contains the component tree React tries to render.
  • fallback contains temporary UI React displays when something inside that tree suspends.
import { Suspense } from "react";

export default function ProfilePage() {
  return (
    <Suspense fallback={<p role="status">Loading profile...</p>}>
      <ProfileDetails />
    </Suspense>
  );
}

On an initial render, if ProfileDetails suspends, React attempts to display the paragraph. If that fallback suspends too, the closest parent boundary takes over. When the required content becomes ready, React renders ProfileDetails.

On an initial render, the nearest-boundary rule determines which fallback React attempts to display. React does not search for a globally preferred loading screen. It uses the closest Suspense ancestor that contains the suspended component, escalating to a parent if that boundary's fallback also suspends. During a Transition or deferred update, React may instead retain already revealed content.

Which fallback answers?page boundaryHeaderclosest boundaryQuestionHintswaitsinnerfallbackpage fallbackonly if inner fallback waits
Suspension stops at the closest boundary unless that boundary’s own fallback suspends.

Suspense also coordinates reveal. Components that share the same closest boundary appear together. A nested boundary creates another reveal point, so an outer section can appear before an inner section is ready.

This differs from manual conditional rendering:

QuestionSuspenseManual isLoading check
Who reports pending work?Supported content is not ready during rendering or streamingApplication code sets and clears state
Who chooses the loading UI?The nearest Suspense boundaryThe component containing the condition
Can siblings reveal together?Yes, when they share a boundaryOnly through coordination written by the application
Does useEffect fetching participate automatically?NoIt can update a loading flag
Who handles rejection?The nearest Error Boundary handles a rejected Promise read with useApplication code usually stores and renders error state

Suspense is render coordination, not a replacement for every condition. A form submission button, an empty search result, and a permission message are ordinary application states. They should remain explicit conditions when no component is waiting for a supported Suspense source.

The createSuspenseResource exercise is useful for recognizing the loading contract in interview code. In application code, prefer documented React APIs or a framework that supports Suspense data loading.

Start With Code Splitting and React.lazy

React.lazy is the clearest first Suspense example because React directly supports code loading through it. The loader runs when React first tries to render the lazy component.

This is a complete standalone code-splitting example:

import { Component, Suspense, lazy } from "react";

const InterviewWorkspace = lazy(() => import("./InterviewWorkspace.jsx"));

class LoadErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  render() {
    if (this.state.error) {
      return <p role="alert">The workspace could not be loaded.</p>;
    }

    return this.props.children;
  }
}

export default function PracticePage() {
  return (
    <LoadErrorBoundary>
      <Suspense fallback={<p role="status">Loading workspace...</p>}>
        <InterviewWorkspace />
      </Suspense>
    </LoadErrorBoundary>
  );
}
export default function InterviewWorkspace() {
  return (
    <section aria-labelledby="workspace-title">
      <h2 id="workspace-title">React practice workspace</h2>
      <p>Write the component, run the tests, and inspect failed cases.</p>
    </section>
  );
}

Declare the lazy component at module scope, outside PracticePage. A declaration inside the component would create a new component type during rendering and can reset its state.

The function passed to lazy returns the Promise from import(). React caches that Promise and its resolved value, so React does not call the loader again after it has started loading the module.

The imported module must have a default property containing a valid React component type. That is why InterviewWorkspace.jsx uses export default.

A rejected module load is an error state, not a successful reveal. The example places an Error Boundary outside Suspense so the page has separate waiting and failure experiences. The fallback covers the pending period. The error boundary covers the failed load.

One load, three outcomesmodulePromiserejectedfulfilledpendingError Boundaryfailure UISuspenseloading UIcomponentrevealedLoading UI never doubles as failure UI.
A lazy module’s Promise sends waiting to Suspense, success to the component, and failure to an Error Boundary.

Code splitting is a common live-coding extension after basic component work. The broader React interview question guide shows where lazy loading fits among rendering, state, and debugging rounds.

Load Data With Suspense and use

In stable React 19.2, use can read a Promise during rendering (React use reference). If that Promise is pending, the component suspends. If it fulfills, use returns its value. If it rejects, the nearest Error Boundary handles the error.

Promise identity matters. React must receive the same cached Promise across renders. Calling fetch() directly in the component creates a new Promise each time the component renders, so React can encounter fresh pending work repeatedly.

The following files form the canonical implementation used by the tests later in this guide.

profileData.js owns the cache:

const profilePromises = new Map();

export function getProfilePromise(profileId) {
  if (!profilePromises.has(profileId)) {
    const request = fetch(`/api/profiles/${profileId}`).then((response) => {
      if (!response.ok) {
        throw new Error("Profile request failed");
      }

      return response.json();
    });

    profilePromises.set(profileId, request);
  }

  return profilePromises.get(profileId);
}

The Map returns the same Promise for the same profile ID. This small example does not implement cache invalidation or retries. Those policies need an explicit owner in a production application.

ProfileScreen.jsx reads the Promise and defines both loading and error UI:

import { Component, Suspense, use } from "react";

export function ProfileCard({ profilePromise }) {
  const profile = use(profilePromise);

  return (
    <article aria-labelledby="profile-name">
      <h2 id="profile-name">{profile.name}</h2>
      <p>{profile.specialty}</p>
    </article>
  );
}

export class ProfileErrorBoundary extends Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <p role="alert">The profile could not be loaded.</p>;
    }

    return this.props.children;
  }
}

export function ProfileScreen({ profilePromise }) {
  return (
    <ProfileErrorBoundary>
      <Suspense fallback={<p role="status">Loading profile...</p>}>
        <ProfileCard profilePromise={profilePromise} />
      </Suspense>
    </ProfileErrorBoundary>
  );
}

App.jsx creates no request during rendering. It asks the cache for the stored Promise before passing it to the screen:

import { ProfileScreen } from "./ProfileScreen.jsx";
import { getProfilePromise } from "./profileData.js";

const adaProfilePromise = getProfilePromise("ada");

export default function App() {
  return <ProfileScreen profilePromise={adaProfilePromise} />;
}

The placement of adaProfilePromise also makes the identity easy to inspect during an interview. Every render of App passes the same object.

This broken version does the opposite:

import { use } from "react";

export function BrokenProfile({ profileId }) {
  const profile = use(
    fetch(`/api/profiles/${profileId}`).then((response) => response.json())
  );

  return <h2>{profile.name}</h2>;
}

Each render calls fetch again and produces another Promise. Wrapping this component in more Suspense boundaries does not repair its unstable input.

Fetching in useEffect is different. Suspense does not detect data fetched inside an Effect or an event handler. An Effect can set isLoading, save a result, and trigger another render, but those state updates do not turn the request into a Suspense data source.

A useful comparison exercise is the mini React Query core, which makes cache identity and request reuse visible without pretending that every cache has the same design.

Design Boundaries Around the Loading Experience

Boundary shape controls revealTogetherPromptEditorTestsProgressivePrompt firstinner reveal pointslower editorIndependentSolutionRelatedSeparate outlines mean separate reveal decisions.
Boundary topology determines which parts share a reveal moment.

Place a boundary around the smallest area that should disappear and reappear as one visual unit. Do not add one boundary around every component by habit.

A single shared boundary works when the content has little value until all its parts are ready:

<Suspense fallback={<PracticePageSkeleton />}>
  <QuestionPrompt />
  <CodeEditor />
  <TestResults />
</Suspense>

All three children share the same nearest boundary, so React reveals them together.

Nested boundaries work when an outer section is useful before a slower inner section:

<Suspense fallback={<PracticePageSkeleton />}>
  <QuestionPrompt />

  <Suspense fallback={<p role="status">Loading editor...</p>}>
    <CodeEditor />
  </Suspense>
</Suspense>

The prompt can remain visible while the editor waits. The outer fallback still applies if QuestionPrompt suspends.

Independent sibling boundaries work when either result is useful by itself:

<>
  <Suspense fallback={<p role="status">Loading solution...</p>}>
    <WorkedSolution />
  </Suspense>

  <Suspense fallback={<p role="status">Loading related questions...</p>}>
    <RelatedQuestions />
  </Suspense>
</>

Use this decision process:

  1. Identify which content is already useful on its own.
  2. Group components that should appear together under one boundary.
  3. Add a nested boundary where a slower child should not hide useful parent content.
  4. Use sibling boundaries when sections can complete independently.
  5. Check the rejected state separately because Suspense fallback UI does not replace an Error Boundary.

Already revealed content needs special care. If a navigation update causes it to suspend again, startTransition can mark that update as non-urgent:

import { startTransition } from "react";

function selectQuestion(nextQuestionId) {
  startTransition(() => {
    setQuestionId(nextQuestionId);
  });
}

For a value such as a search query, useDeferredValue can let a result component continue receiving an older value while the new result waits:

import { Suspense, useDeferredValue } from "react";

export function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);

  return (
    <Suspense fallback={<p role="status">Loading results...</p>}>
      <ResultList query={deferredQuery} />
    </Suspense>
  );
}

Both APIs can prevent already revealed content from being replaced by an unwanted fallback. They solve presentation timing, not Promise caching or error handling.

When revealed content waits againUrgent updateold resultsvisiblenew queryfallback replacesold resultsTransition or deferred valueold results stayusefulnew work waitsnew resultsreveal laterThe Promise still needs a cache; failures still need anError Boundary.
Transitions change what remains visible during a resuspending update, not how the request is cached.

Suspense also affects server rendering. During streaming server rendering, React can send a shell containing fallback UI first, then stream a boundary's content when it becomes ready. Selective hydration lets React make available parts of that server-rendered interface interactive without requiring every boundary to finish first.

The page arrives in useful piecesReactservershell firstready headerboundary slotfallback firstready controlsboundary HTMLarrives laterhydration can begin hereOther boundaries do not have to finish first.
Streaming sends the shell first, fills Suspense slots later, and hydrates available regions independently.

This guide targets stable React 19.2 behavior. The current official references classify <ViewTransition> as available only in the Canary and Experimental channels, font and image coordination during View Transition updates as Canary-only, and the defer prop as Experimental-only. Treat none of them as stable interview answers (React Suspense reference, <ViewTransition> reference).

Handle Errors and Test Every State

A complete Suspense test covers three distinct outcomes:

  • Pending work displays the fallback.
  • Fulfilled work replaces the fallback with content.
  • Rejected work displays Error Boundary UI.

Use a controllable deferred Promise instead of a timer or network call. The test decides exactly when the Promise settles.

These tests target the canonical ProfileScreen implementation. They run with Node 22 and Vitest 4.1.11. Create a project, install the pinned dependencies, and save the implementation files from this guide under src/:

npm init -y
npm install [email protected] [email protected]
npm install --save-dev [email protected] @vitejs/[email protected] [email protected] [email protected] @testing-library/[email protected] @testing-library/[email protected] @testing-library/[email protected]

Create vitest.config.mjs:

import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [react()],
  test: { environment: "jsdom" },
});

Save the following test as src/ProfileScreen.test.jsx. A blank ProfileScreen starter makes all three tests fail; implement pending, fulfilled, and rejected behavior until this command passes:

npx vitest run
import "@testing-library/jest-dom/vitest";
import { act } from "react";
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { ProfileScreen } from "./ProfileScreen.jsx";

function createDeferred() {
  let resolve;
  let reject;

  const promise = new Promise((resolvePromise, rejectPromise) => {
    resolve = resolvePromise;
    reject = rejectPromise;
  });

  return { promise, resolve, reject };
}

test("shows the fallback while the profile is pending", () => {
  const deferred = createDeferred();

  render(<ProfileScreen profilePromise={deferred.promise} />);

  expect(screen.getByRole("status")).toHaveTextContent("Loading profile...");
});

test("reveals the profile after the Promise fulfills", async () => {
  const deferred = createDeferred();

  render(<ProfileScreen profilePromise={deferred.promise} />);

  await act(async () => {
    deferred.resolve({
      name: "Ada",
      specialty: "React component debugging",
    });
  });

  expect(
    screen.getByRole("heading", { name: "Ada" })
  ).toBeInTheDocument();
  expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

test("shows error UI after the Promise rejects", async () => {
  const deferred = createDeferred();

  render(<ProfileScreen profilePromise={deferred.promise} />);

  await act(async () => {
    deferred.reject(new Error("Request failed"));
  });

  expect(screen.getByRole("alert")).toHaveTextContent(
    "The profile could not be loaded."
  );
  expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

React's asynchronous act helper flushes updates that cross asynchronous boundaries before the assertions run. The tests do not wait for an arbitrary number of milliseconds, so a slow machine does not change the intended sequence.

Notice the division of responsibility. The status element belongs to Suspense and appears only while work is pending. The alert belongs to the Error Boundary and appears after rejection. A test that checks only the spinner misses both successful replacement and failed loading.

Test the state change, not the clockdeferred Promisependingstatus: Loadingresolverejectfulfilledprofile headingrejectederror alertNo timer decides when either branch runs.
Drive the same deferred Promise through pending, fulfilled, and rejected assertions.

The ErrorBoundary exercise provides focused practice with recovery UI before combining it with a Suspense data flow.

React Suspense Interview Exercises

Use the Vitest project above as the exercise harness. Save each starter as src/Exercise.jsx, render it from src/Exercise.test.jsx, and run npx vitest run. Write an assertion for the stated success criteria and confirm that it fails before implementing the repair. Keep each worked answer closed until your test passes or you have explained why it fails.

Predict the active Suspense fallback

Consider this tree:

<Suspense fallback={<p>Loading page...</p>}>
  <Header />

  <Suspense fallback={<p>Loading question...</p>}>
    <Question />
    <Hints />
  </Suspense>
</Suspense>

Prompt: Hints suspends, while Header and Question can render. Which fallback appears?

Success criteria: name the attempted fallback and explain whether Question reveals independently.

Reveal worked answer

Worked answer: Loading question... appears because the inner boundary is the nearest Suspense ancestor of Hints. Question shares that closest boundary, so the inner content is coordinated and does not reveal until the suspended work is ready. The outer page fallback is not selected for this suspension.

Repair an uncached Promise loop

Prompt: diagnose and repair this component:

Success criteria: repeated renders reuse one Promise for each candidateId, and a non-OK response rejects so an Error Boundary can handle it.

function CandidateProfile({ candidateId }) {
  const candidate = use(
    fetch(`/api/candidates/${candidateId}`).then((response) => response.json())
  );

  return <p>{candidate.name}</p>;
}
Reveal worked answer

Worked answer: the component creates a new Promise during every render. Move request creation into a cache or receive an already cached Promise through props.

const candidatePromises = new Map();

function getCandidatePromise(candidateId) {
  if (!candidatePromises.has(candidateId)) {
    candidatePromises.set(
      candidateId,
      fetch(`/api/candidates/${candidateId}`).then((response) => {
        if (!response.ok) {
          throw new Error("Candidate request failed");
        }

        return response.json();
      })
    );
  }

  return candidatePromises.get(candidateId);
}

function CandidateProfile({ candidatePromise }) {
  const candidate = use(candidatePromise);
  return <p>{candidate.name}</p>;
}

The repaired component receives a stable Promise. Suspense handles pending work, while an Error Boundary outside the component handles rejection.

Refactor manual loading flags into intentional boundaries

Prompt: a page hides the question, editor, and test history behind one isLoading condition. The question arrives quickly, but the editor and history can become ready independently. Choose boundaries that preserve useful content.

Success criteria: the question remains useful as soon as it is ready, the editor and history can reveal independently, and an empty history remains ordinary conditional UI.

Reveal worked answer

Worked answer: keep the question under the page boundary if the page cannot function without it. Give the editor and history separate sibling boundaries after the question so either section can reveal when ready. Keep ordinary states, such as an empty history list, as explicit conditional rendering.

<Suspense fallback={<PracticePageSkeleton />}>
  <QuestionPrompt />

  <Suspense fallback={<p role="status">Loading editor...</p>}>
    <CodeEditor />
  </Suspense>

  <Suspense fallback={<p role="status">Loading test history...</p>}>
    <TestHistory />
  </Suspense>
</Suspense>

A concise spoken answer can sound like this:

“Suspense coordinates a subtree while supported content is not ready. On an initial render, React attempts the closest boundary's fallback and reveals content when it is ready. Common activators are lazy and a cached Promise read with use; the Promise must stay stable across retries. Rejections go to an Error Boundary, not the loading fallback. I place boundaries around content that should reveal together and test pending, fulfilled, and rejected outcomes with a controllable Promise.”

Score an answer from 0 to 2 in each area:

Criterion0 points1 point2 points
CorrectnessTreats Suspense as any loading flagDescribes fallback and revealAlso explains closest-boundary behavior and its qualifications
Promise stabilityOmits identitySays to cacheExplains why retries must receive the same Promise
Boundary rationaleAdds boundaries arbitrarilyGroups related contentDefends reveal order and independent sections
Rejection handlingUses the loading fallback for errorsNames an Error BoundarySeparates pending and failed UI clearly
Deterministic testingTests only a spinner or uses timersTests pending and successControls a Promise and tests pending, fulfilled, and rejected outcomes

An acceptable answer scores at least 6 out of 10 without a zero for correctness or rejection handling. A strong answer scores 9 or 10.

For more timed component practice, the React JavaScript interview guide helps connect Suspense questions to the rendering and debugging skills interviewers examine. UIReady Premium Lifetime is relevant when a structured queue of additional UI exercises is more useful than choosing the next drill manually.

Frequently asked questions

What does React Suspense do?
React Suspense coordinates a boundary while supported content is not ready, normally showing fallback UI and revealing the content later. Stable React 19.2 activators include lazy code, cached Promises read with use, precedence-bearing stylesheets, and boundary HTML arriving during streaming server rendering.
Does fetching data in useEffect activate Suspense?
No. Suspense does not detect data fetched inside an Effect or an event handler. Use a documented Suspense data source, such as a cached Promise read with use or a framework with Suspense support.
Why must a Promise passed to use be cached?
The component must receive the same Promise instance across renders. Creating a new Promise during each render can suspend repeatedly because every render introduces unfinished work.
Does a rejected Promise show the Suspense fallback?
A pending Promise activates the Suspense fallback. A rejected Promise is handled by the nearest Error Boundary, so loading and failure need separate UI.