Overview
Suspense, transitions, and rendering priority concepts are React tools for keeping user interfaces responsive while code or data loads and while expensive UI updates are prepared. Suspense lets part of the tree show a fallback while its children are not ready. Transitions let React treat some state updates as non-urgent so urgent interactions, such as typing and clicking, can stay responsive. Rendering priority is the practical idea that not all UI updates have the same urgency.
This topic matters because modern React applications often combine route navigation, lazy-loaded components, server data, search results, dashboards, and expensive client rendering. Without good boundaries, a small interaction can hide the whole page behind a spinner or make typing feel blocked by expensive result rendering.
In interviews, candidates are expected to explain Suspense boundaries, React.lazy, useTransition, startTransition, useDeferredValue, pending UI, error boundaries, and the difference between urgent and non-urgent updates. Strong answers focus on user experience and correct mental models instead of relying on scheduler internals.
The practical goal is to keep already useful UI visible, show loading states at the right level, and let React interrupt or delay non-urgent work when the user does something more important.
Core Concepts
Render, Commit, and Paint
React updates the UI in stages:
- Trigger: a state update, prop change, context change, external store update, or initial mount starts work.
- Render: React calls components to calculate the next UI.
- Commit: React applies the necessary DOM changes.
- Paint: the browser displays the updated page.
Rendering priority is about deciding which render work should happen urgently and which work can be delayed, interrupted, or prepared in the background. React does not require application developers to manage numeric priorities. Instead, developers use APIs such as startTransition, useTransition, useDeferredValue, and Suspense boundaries.
Urgent vs Non-Urgent Updates
Urgent updates should reflect immediately:
- Typing into a controlled input.
- Clicking a button that changes pressed or selected state.
- Opening a menu.
- Moving focus.
- Updating a checkbox value.
Non-urgent updates can wait briefly:
- Rendering a heavy search result list.
- Switching a tab with expensive content.
- Navigating to a new route.
- Showing filtered dashboard charts.
- Rendering a markdown preview.
This distinction helps avoid blocking immediate feedback with expensive rendering.
Suspense
<Suspense> displays a fallback until its children are ready.
import { Suspense } from "react";
export function ProductPage() {
return (
<Suspense fallback={<ProductPageSkeleton />}>
<ProductDetails />
</Suspense>
);
}
A child can suspend while loading code, data, or another async dependency supported by the framework or library. When that happens, React shows the nearest Suspense fallback. When the child is ready, React retries rendering the suspended tree.
Important behavior:
- Suspense handles loading states, not error states.
- Failed lazy imports or data loads need an error boundary.
- If a component suspends before its first mount completes, React does not preserve its unmounted state.
- A fallback should be lightweight and sized appropriately for the surrounding UI.
Suspense Boundaries
A Suspense boundary controls how much UI is replaced by a loading fallback. A boundary near the root can hide a large part of the app. A boundary around a small panel can keep the rest of the page usable.
Broad boundary:
<Suspense fallback={<FullPageSpinner />}>
<AppRoutes />
</Suspense>
Focused boundary:
<Layout>
<Sidebar />
<Suspense fallback={<ReportSkeleton />}>
<ReportPanel />
</Suspense>
</Layout>
Good boundary placement depends on the desired loading sequence. Use larger boundaries when content should reveal together. Use nested boundaries when different sections can load independently.
React.lazy and Code Loading
React.lazy defers loading component code until the component is first rendered. While the component code is loading, rendering suspends and the nearest Suspense fallback appears.
import { lazy, Suspense } from "react";
const AdminReports = lazy(() => import("./AdminReports"));
export function AdminRoute() {
return (
<Suspense fallback={<p>Loading reports...</p>}>
<AdminReports />
</Suspense>
);
}
Important rules:
- Declare lazy components at module scope.
- The imported module must provide a default export.
- Wrap lazy components in Suspense.
- Add an error boundary for failed chunk loads.
- Lazy loading improves initial load only when the split code is not needed immediately.
Transitions
A transition marks a state update as non-urgent. React can keep urgent updates responsive while preparing the transitioned UI.
import { useState, useTransition } from "react";
function ProductTabs() {
const [tab, setTab] = useState("details");
const [isPending, startTransition] = useTransition();
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<TabButtons selected={tab} onSelect={selectTab} />
{isPending && <span>Loading...</span>}
<TabPanel tab={tab} />
</>
);
}
useTransition returns:
isPending: whether transition work is pending.startTransition: a function used to mark updates as transitions.
Transitions are useful for navigation, tab changes, heavy result rendering, and updates that may suspend. They are not for controlling text input values directly.
startTransition
startTransition is also available as a standalone API for marking updates as non-urgent outside a component hook return value.
import { startTransition } from "react";
function navigate(to: string) {
startTransition(() => {
routerStore.setPath(to);
});
}
Use useTransition when the component needs a pending indicator. Use standalone startTransition when the caller does not need local pending state.
Be careful with async code. Keep the state updates that should be treated as a transition inside the transition action. If state updates happen after an await, make sure those later updates are also marked as transition updates according to the React version and framework behavior used by the project.
Controlled Inputs Should Stay Urgent
Controlled input values should update urgently so typing stays responsive.
Bad:
function SearchBox() {
const [query, setQuery] = useState("");
const [, startTransition] = useTransition();
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
startTransition(() => {
setQuery(event.target.value);
});
}
return <input value={query} onChange={onChange} />;
}
Better:
function SearchBox() {
const [query, setQuery] = useState("");
const [resultsQuery, setResultsQuery] = useState("");
const [isPending, startTransition] = useTransition();
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextQuery = event.target.value;
setQuery(nextQuery);
startTransition(() => {
setResultsQuery(nextQuery);
});
}
return (
<>
<input value={query} onChange={onChange} />
{isPending && <p>Updating results...</p>}
<Results query={resultsQuery} />
</>
);
}
The input updates immediately. The expensive results update can be prepared as non-urgent work.
useDeferredValue
useDeferredValue lets a component use a deferred version of a value. It is useful when the component receives a value from props or state and you want a slower part of the UI to lag behind urgent updates.
import { useDeferredValue, useState } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</>
);
}
Use useDeferredValue when:
- The source value must update urgently.
- A derived subtree can update later.
- You do not control the original state setter.
- You want to keep stale content visible while fresh content is prepared.
Avoid passing a new object created during render directly to useDeferredValue, because it will be different on every render and cause unnecessary background rendering.
Preventing Unwanted Fallbacks
Without transitions, an already visible UI can be replaced by a Suspense fallback during an update. This can feel jarring when a user navigates or changes tabs.
function Router() {
const [page, setPage] = useState("/");
function navigate(nextPage: string) {
startTransition(() => {
setPage(nextPage);
});
}
return <RouteContent page={page} navigate={navigate} />;
}
Marking navigation as a transition tells React that it is better to keep already revealed content visible while preparing the next screen, instead of immediately hiding it behind a large fallback.
Pending UI
Transitions should still communicate progress. A pending indicator can be subtle:
- Dim stale results.
- Show a small inline spinner near navigation.
- Disable duplicate submit buttons while preserving layout.
- Add a progress bar at the route level.
- Use skeletons inside newly revealed panels.
Avoid replacing the whole page with a spinner for small updates. The user should understand that work is happening without losing useful context.
Error Boundaries
Suspense handles waiting. Error boundaries handle failures.
<ErrorBoundary fallback={<p>Could not load reports.</p>}>
<Suspense fallback={<ReportSkeleton />}>
<Reports />
</Suspense>
</ErrorBoundary>
This pairing is common for lazy-loaded routes and data-loading sections. The Suspense fallback covers loading. The error boundary covers failed chunk loads, rejected async work, or render errors handled by the framework.
Rendering Priority Concepts Without Internals
For interviews, explain rendering priority at the application level:
- Urgent work should update immediately because the user is directly interacting with it.
- Non-urgent work can be interrupted, delayed, or prepared in the background.
- Suspense boundaries decide what loading UI appears.
- Transitions mark updates that can avoid hiding already revealed content.
- Deferred values let part of the UI lag behind an urgent value.
Avoid claiming that application code controls low-level scheduler lanes directly. React exposes high-level APIs, not a public priority queue for normal application work.
Common Mistakes
Common mistakes include:
- Wrapping controlled input value updates in transitions.
- Putting one Suspense boundary around the whole app and hiding useful layout during small loads.
- Forgetting error boundaries around lazy-loaded or async content.
- Using Suspense as if it catches errors.
- Showing full-page spinners for every route or panel update.
- Creating new objects for
useDeferredValueon every render. - Assuming transitions make slow code faster instead of making work interruptible or less urgent.
- Forgetting pending UI, leaving the user with stale content and no signal.
- Depending on unsupported scheduler internals in application code.
Best Practices
Best practices include:
- Keep input state urgent.
- Transition route changes, tab changes, and expensive result updates.
- Place Suspense boundaries around meaningful loading regions.
- Use nested boundaries for progressive reveal.
- Keep fallback UI lightweight and layout-stable.
- Use error boundaries with Suspense for failure cases.
- Use
useDeferredValuewhen a subtree can trail behind a fast-changing value. - Show pending or stale states without hiding useful content.
- Profile slow transitions and fix expensive rendering, not just loading indicators.