React 77 ⚛️ Loading States with Suspense
A loading state is not a spinner. It is a contract between the application and the user: the application has accepted the request, is working on it, and will show the result as soon as it is ready. The traditional way to implement this contract is a boolean flag—const [isLoading, setIsLoading] = useState(true)—checked at the top of every component that fetches data. This works, but it forces every component to carry its own loading and error scaffolding, and it produces a UI that flickers as each component resolves at a different time . Suspense inverts this: the loading boundary is declared once, at the region level, and the child components render as if the data is always present. The boundary handles the gap.
React 19 made Suspense viable for data fetching through the use() hook. Before React 19, Suspense only worked with React.lazy and frameworks like Relay and Next.js that implemented their own Suspense-compatible data sources . The use() hook lets a component read a promise directly, and if the promise is pending, React suspends the component and shows the nearest boundary’s fallback. This is the mechanism that makes the loading state declarative rather than imperative: the component does not know it is loading, it just tries to read the data, and React handles the wait .
Key point: Suspense moves loading from a per-component concern to a per-region concern; the component calls use(promise) and renders as if the data is present, while the nearest <Suspense> boundary shows the fallback until the promise resolves, and useTransition or useDeferredValue prevents the fallback from replacing already-visible content during navigation and search.
Why Suspense changes the loading model
The scattered flag problem. In the traditional model, every component that fetches data maintains its own isLoading state and its own conditional rendering. A page with five data-dependent components has five loading flags, five spinners, and five independent resolve times. The user sees a cascade of layout shifts as each component pops in . The page feels unstable not because it is slow, but because the loading states are uncoordinated.
The region problem. Suspense makes the boundary the unit of loading, not the component. A single <Suspense> around a region means all the components inside it reveal together. The fallback is shown once, in one place, and replaced once, when the data is ready. This matches how designers think about loading—they design a skeleton for the page section, not a spinner for each widget . The boundary placement determines the loading experience, and moving a boundary changes the experience without changing the components inside it.
The stale-content problem. When a user navigates from one page to another, the traditional model unmounts the old page and shows a spinner for the new one. Suspense with startTransition does something different: it keeps the old page visible while the new page loads in the background, and swaps them atomically when the new page is ready . The user never sees a blank screen or a spinner replace content they were reading. The isPending flag from useTransition provides the signal that something is loading, without replacing the content.
The framework problem. Next.js implements the loading.js convention on top of Suspense. A loading.tsx file in a route folder is automatically wrapped around the page in a <Suspense> boundary, and the fallback is prefetched so navigation feels instant . This is the framework-level implementation of the same pattern: the boundary is declared by file convention, and the developer provides a skeleton that matches the final layout.
a. The basic pattern: use() and <Suspense>
The use() hook reads a promise. If the promise is pending, the component suspends and the nearest <Suspense> boundary shows its fallback. When the promise resolves, React re-renders the component with the data.
// Import use and Suspense from React
import { use, Suspense } from 'react'
// The promise is created outside the component
// creating it inside the render would create a new promise every render
// and trigger an infinite suspend-render loop
function fetchUser(id) {
return fetch(`/api/users/${id}`).then((r) => r.json())
}
function UserProfile({ userPromise }) {
// use() reads the promise
// if it is pending, React suspends this component
// and shows the nearest Suspense fallback
const user = use(userPromise)
// This code runs only when the promise has resolved
// there is no isLoading flag and no conditional rendering
return <h1>{user.name}</h1>
}
function App() {
// The promise is created once, outside the component that uses it
// a stable promise is required for use() to work correctly
const userPromise = fetchUser(1)
return (
// The Suspense boundary shows the fallback while the promise is pending
// it can be placed anywhere above the component that calls use()
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
The userPromise is created in App and passed down. This is deliberate. If UserProfile created the promise itself (const user = use(fetchUser(1))), every render would create a new promise, which would suspend again, which would trigger another render, and the component would loop indefinitely . The promise must be stable across renders, which means it must be created outside the component that reads it, or memoized with useMemo, or cached by a data-fetching library.
The Suspense boundary can be placed anywhere above the suspending component. A boundary near the root catches any suspension in the tree. A boundary near the component localizes the fallback to that section. The placement determines the loading granularity.
b. Nested and parallel boundaries
A single boundary treats everything inside it as one unit. If one component suspends, the entire region shows the fallback. Nested boundaries allow different sections to load independently and reveal in sequence .
// Import the components and Suspense
import { Suspense } from 'react'
import Biography from './Biography'
import Albums from './Albums'
function ArtistPage({ artist }) {
return (
// The outer boundary covers the whole page
// it shows a full-page spinner until Biography is ready
<Suspense fallback={<h2>Loading page...</h2>}>
{/* Biography is inside the outer boundary */}
<Biography artistId={artist.id} />
{/* The inner boundary covers only Albums */}
// Albums has its own fallback
// it can load after Biography has already appeared
<Suspense fallback={<h2>Loading albums...</h2>}>
<Albums artistId={artist.id} />
</Suspense>
</Suspense>
)
}
The sequence is: the outer fallback shows until Biography resolves. Then Biography appears, and if Albums is still loading, the inner fallback shows in the Albums area. When Albums resolves, it replaces the inner fallback. The user sees the page in stages, with each stage appearing as soon as it is ready . This is the “progressive disclosure” pattern: the critical content appears first, and the secondary content fills in.
Parallel boundaries are the opposite: two independent sections, each with its own boundary, placed side by side. Both start loading at the same time because both are rendered at the same time. The faster one reveals first, and the slower one shows its fallback until it is ready .
function Dashboard() {
return (
<div className="grid">
{/* Each section has its own boundary */}
{/* both start loading in parallel */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<Activity />
</Suspense>
</div>
)
}
The parallel pattern is correct when the sections are independent. The nested pattern is correct when the sections have a dependency or a desired reveal order. The choice is a design decision, not a technical one.
c. Transitions and deferred values: avoiding the fallback flash
The fallback flash happens when a navigation or a search replaces already-visible content with a spinner. The user was reading something, clicked a link or typed a character, and the content disappeared. This is jarring even if the new content loads quickly. useTransition and useDeferredValue solve it by keeping the old content visible while the new content loads.
useTransition marks a state update as non-urgent. React keeps the current UI visible and starts rendering the new UI in the background. The isPending flag tells the application that the transition is in progress, so it can show a subtle indicator—a dimmed opacity, a small spinner in a corner—without replacing the content .
// Import useTransition and Suspense
import { useState, useTransition, Suspense } from 'react'
function Router() {
// The current route and the transition state
const [page, setPage] = useState('/')
// isPending is true while the new page is loading
const [isPending, startTransition] = useTransition()
function navigate(path) {
// Mark the navigation as a transition
// React keeps the old page visible until the new one is ready
startTransition(() => {
setPage(path)
})
}
return (
<div>
{/* The nav dims while the transition is pending */}
<nav style={{ opacity: isPending ? 0.5 : 1 }}>
<button onClick={() => navigate('/')}>Home</button>
<button onClick={() => navigate('/about')}>About</button>
</nav>
{/* The Suspense boundary shows the fallback only if no content is visible */}
{/* because the update is a transition, the old page stays visible */}
<Suspense fallback={<div>Loading page...</div>}>
<PageContent path={page} />
</Suspense>
</div>
)
}
Without startTransition, clicking a navigation button would immediately show the Suspense fallback, replacing the current page. With startTransition, the current page stays visible, the navigation happens in the background, and the new page swaps in atomically when it is ready . The isPending flag provides the loading signal without disrupting the content.
useDeferredValue solves the same problem for a value that comes from outside the component’s state, such as a search query or a prop. The input updates immediately, but the expensive part of the UI—the results list—uses the deferred value, which lags behind until the new results are ready .
// Import useDeferredValue and Suspense
import { useState, useDeferredValue, Suspense } from 'react'
function SearchPage() {
// The input value updates immediately
const [query, setQuery] = useState('')
// The deferred value lags behind the input
// it updates only when React has time to render the results
const deferredQuery = useDeferredValue(query)
// isStale is true when the deferred value is behind the input
const isStale = query !== deferredQuery
return (
<div>
{/* The input is always responsive */}
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* The results dim while the deferred value is catching up */}
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<Suspense fallback={<div>Loading results...</div>}>
<SearchResults query={deferredQuery} />
</Suspense>
</div>
</div>
)
}
The isStale check is the visual signal. The results dim to half opacity while the deferred value is behind, and return to full opacity when it catches up. The user sees that the results are updating without the list disappearing . The SearchResults component must be memoized (React.memo) for useDeferredValue to skip re-rendering it on every keystroke .
Complete Example Session
The following session builds a user profile page with parallel data sections, a navigation transition, and a deferred search, demonstrating the three loading patterns.
// ============================================
// PART 1: THE DATA FUNCTIONS
// ============================================
// Each function returns a promise that resolves to data
function fetchProfile(id) {
return fetch(`/api/profile/${id}`).then((r) => r.json())
}
function fetchPosts(id) {
return fetch(`/api/posts/${id}`).then((r) => r.json())
}
// ============================================
// PART 2: COMPONENTS THAT USE THE DATA
// ============================================
import { use } from 'react'
function ProfileHeader({ profilePromise }) {
// use() reads the promise
// the component suspends until the promise resolves
const profile = use(profilePromise)
return <h1>{profile.name}</h1>
}
function PostList({ postsPromise }) {
// use() reads the promise
const posts = use(postsPromise)
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// ============================================
// PART 3: PARALLEL BOUNDARIES
// ============================================
import { Suspense } from 'react'
function ProfilePage({ userId }) {
// Both promises are created at the same time
// they start loading in parallel, not sequentially
const profilePromise = fetchProfile(userId)
const postsPromise = fetchPosts(userId)
return (
<div>
{/* Each section has its own boundary */}
{/* the header can appear before the posts */}
<Suspense fallback={<div>Loading header...</div>}>
<ProfileHeader profilePromise={profilePromise} />
</Suspense>
<Suspense fallback={<div>Loading posts...</div>}>
<PostList postsPromise={postsPromise} />
</Suspense>
</div>
)
}
// ============================================
// PART 4: TRANSITION-GUARDED NAVIGATION
// ============================================
import { useState, useTransition } from 'react'
function App() {
const [userId, setUserId] = useState(1)
// isPending signals that a transition is in progress
const [isPending, startTransition] = useTransition()
function switchUser(id) {
// Wrap the state update in startTransition
// the old profile stays visible until the new one is ready
startTransition(() => {
setUserId(id)
})
}
return (
<div style={{ opacity: isPending ? 0.5 : 1 }}>
{/* The buttons switch between users */}
<button onClick={() => switchUser(1)}>User 1</button>
<button onClick={() => switchUser(2)}>User 2</button>
{/* The Suspense boundary shows a fallback only if nothing is visible */}
{/* with startTransition, the old profile stays visible */}
<Suspense fallback={<div>Loading profile...</div>}>
<ProfilePage userId={userId} />
</Suspense>
</div>
)
}
The four parts cover the data functions, the components that read the promises with use(), the parallel boundaries that let the header and posts load independently, and the transition-guarded navigation that keeps the old profile visible while the new one loads. What the session demonstrates is that Suspense does not require a loading flag in any component—the components read data as if it is always present, and the boundaries handle the gap.
Quick Reference
Suspense API
| API | Purpose |
|---|---|
<Suspense fallback={...}> | Show fallback while children load |
use(promise) | Read a promise, suspend if pending |
useTransition() | Mark update as non-urgent, keep old UI |
useDeferredValue(value) | Defer a value, keep input responsive |
When Suspense Activates
| Source | Activates Suspense? |
|---|---|
use(promise) | Yes |
React.lazy | Yes |
| Frameworks (Next.js, Relay) | Yes |
useEffect + fetch | No |
Boundary Placement
| Pattern | Use Case |
|---|---|
| Single root boundary | Simple apps, full-page loading |
| Nested boundaries | Sequential reveal, dependent sections |
| Parallel boundaries | Independent sections |
| Per-route boundary | Navigation transitions |
Fallback Design
| Fallback | When to Use |
|---|---|
| Skeleton | Matches final layout, reduces layout shift |
| Spinner | Generic, last resort |
| Dimmed content | Transition is pending, content is stale |
Best Practices
✅ Do This:
// Create the promise outside the component that reads it
const profilePromise = fetchProfile(userId)
<Suspense fallback={<Skeleton />}><Profile profilePromise={profilePromise} /></Suspense>
// Use parallel boundaries for independent sections
<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense>
<Suspense fallback={<ActivitySkeleton />}><Activity /></Suspense>
// Use startTransition for navigation
startTransition(() => setUserId(id))
// Use useDeferredValue for search
const deferredQuery = useDeferredValue(query)
❌ Don’t Do This:
// Create the promise inside the component
const data = use(fetchData()) // new promise every render, infinite loop
// Use Suspense for useEffect fetching
useEffect(() => { fetch(...) }, []) // Suspense does not see this
// Wrap every component in its own boundary
<Suspense><Button /></Suspense> // too granular, adds overhead
// Show a spinner for a navigation that could keep content visible
<Suspense fallback={<Spinner />}><Page /></Suspense> // without startTransition
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Infinite suspend loop | Promise created inside render | Create promise outside or memoize |
| Suspense never activates | Data fetched in useEffect | Use use() or a Suspense-aware library |
| Fallback flash on navigation | No startTransition | Wrap navigation update |
| Layout shift on reveal | Fallback does not match final layout | Use skeleton, not spinner |
use() outside boundary | No Suspense ancestor | Wrap in <Suspense> |
Real-World Examples
1. Basic use with Suspense
const user = use(userPromise)
<Suspense fallback={<Spinner />}><Profile userPromise={userPromise} /></Suspense>
2. Nested boundaries
<Suspense fallback={<PageSpinner />}>
<Header />
<Suspense fallback={<ListSkeleton />}><List /></Suspense>
</Suspense>
3. Parallel boundaries
<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense>
<Suspense fallback={<FeedSkeleton />}><Feed /></Suspense>
4. Transition navigation
startTransition(() => setPage('/about'))
5. Deferred search
const deferredQuery = useDeferredValue(query)
<div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
6. Skeleton fallback
<Suspense fallback={<div className="h-8 w-48 bg-gray-200 animate-pulse rounded" />}>
7. Error boundary around Suspense
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Spinner />}><Profile /></Suspense>
</ErrorBoundary>
8. Next.js loading.tsx
export default function Loading() { return <Skeleton /> }
9. Multiple promises in one boundary
function Page() {
const user = use(userPromise)
const posts = use(postsPromise)
return <div>{user.name}<Posts posts={posts} /></div>
}
10. isPending indicator
const [isPending, startTransition] = useTransition()
<div style={{ opacity: isPending ? 0.5 : 1 }}>
Visual
Traditional Loading vs Suspense
┌──────────────────────────────────────────────┐
│ TRADITIONAL │
│ Component A: isLoading ? Spinner : Content │
│ Component B: isLoading ? Spinner : Content │
│ Component C: isLoading ? Spinner : Content │
│ → cascade of independent spinners │
│ │
│ SUSPENSE │
│ <Suspense fallback={<Skeleton />}> │
│ <Component A /> │
│ <Component B /> │
│ <Component C /> │
│ </Suspense> │
│ → one skeleton, one reveal │
└──────────────────────────────────────────────┘
Nested Boundaries
┌──────────────────────────────────────────────┐
│ <Suspense fallback={<PageSpinner />}> │
│ <Biography /> ← loads first │
│ <Suspense fallback={<AlbumsSkeleton />}> │
│ <Albums /> ← loads after Biography │
│ </Suspense> │
│ </Suspense> │
│ │
│ Sequence: PageSpinner → Biography → Albums │
└──────────────────────────────────────────────┘
Transition Navigation
┌──────────────────────────────────────────────┐
│ WITHOUT startTransition │
│ Click link → fallback replaces content │
│ → jarring flash │
│ │
│ WITH startTransition │
│ Click link → old content stays visible │
│ → new content loads in background │
│ → swap happens atomically │
│ → isPending dims the nav │
└──────────────────────────────────────────────┘
Deferred Search
┌──────────────────────────────────────────────┐
│ query = "react" (immediate) │
│ deferredQuery = "react" (lagged) │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ query deferredQuery │
│ = "react" = "reac" │
│ │ │ │
│ ▼ ▼ │
│ Input Results dimmed │
│ updated (stale indicator) │
│ │ │
│ ▼ │
│ deferredQuery catches up │
│ → results update, opacity returns to 1 │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Activation | use(promise), React.lazy, frameworks |
| Boundary | <Suspense fallback={...}> |
| Promise location | Outside the component |
| Nested boundaries | Sequential reveal |
| Parallel boundaries | Independent sections |
| Transition | startTransition keeps old UI |
| Deferred value | useDeferredValue keeps input responsive |
| Fallback | Skeleton matches layout |
| Error handling | ErrorBoundary around Suspense |
Key takeaways:
- Suspense moves loading to the boundary. The component reads data with
use()and renders as if it is present. The boundary shows the fallback until the data is ready. NoisLoadingflag is needed in the component itself . - The promise must be created outside the component. Creating it inside the render creates a new promise every render, which suspends again, which triggers another render. The component must receive a stable promise, or use a cache .
- Nested boundaries allow sequential reveal. The outer boundary shows a page-level fallback, and inner boundaries show section-level fallbacks as the outer content appears. This is the progressive disclosure pattern .
- Parallel boundaries allow independent loading. Two sections with separate boundaries start loading at the same time and reveal as each is ready. This is correct when the sections have no dependency .
startTransitionprevents the fallback flash. Without it, navigation immediately replaces the current page with the fallback. With it, the current page stays visible while the new page loads, and the swap is atomic .useDeferredValuekeeps search responsive. The input updates immediately, and the results list lags behind with a visual staleness indicator. The user can keep typing while the results catch up .- The fallback should match the final layout. A skeleton that mirrors the shape of the content reduces layout shift and perceived load time compared to a generic spinner .
Remember: Suspense is not a spinner component. It is a change in how the application thinks about loading. Instead of every component managing its own boolean flag and conditional rendering, the loading boundary is declared once, at the region level, and the components inside it are written as if the data is always available. The boundary handles the gap. The use() hook is what makes this possible in React 19 for data fetching, and useTransition and useDeferredValue are what prevent the boundary from disrupting content that is already visible. The goal is not to show a loading indicator everywhere—it is to show the right content at the right time, and to make the waiting invisible where possible.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!