From Manual Loading Flags to Suspense: A Real Migration
Most React apps built before 2025 used manual loading flags with useState and useEffect. Migrating to Suspense is not a rip-and-replace; it's a structured refactoring that eliminates boilerplate, improves rendering performance, and simplifies error handling. This article walks you through a real-world migration, from identifying candidates to testing and rolling out safely.
The key insight: Suspense shifts state management from inside components (loading, error, data flags) to the component tree level (boundaries and fallbacks).
Before and After: The Classic Pattern
Here's the classic pattern found in thousands of React apps:
// Before: Manual loading state
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Name: {user.name}</div>;
}
This component is self-contained: it fetches, tracks state, and renders. The same pattern repeats in every component that needs data. With Suspense:
// After: Suspense-based rendering
import { use, Suspense } from 'react';
function UserProfile({ userPromise }) {
const user = use(userPromise);
return <div>Name: {user.name}</div>;
}
function Page({ userId }) {
const userPromise = fetch(`/api/users/${userId}`).then(r => r.json());
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
The component is now stateless and purely presentational. The boundary handles loading coordination. No repeated boilerplate.
Step 1: Identify Candidates for Migration
Not every component needs migration immediately. Prioritize:
- High-impact components: Components that fetch data and are used in many places (pages, modals, widgets).
- Simple fetches: Components that fetch one or two data sources (more complex multi-source fetches require careful planning).
- Nested hierarchies: Components with children that also fetch data (Suspense shines here, eliminating waterfalls).
Look for patterns:
useState(loading),useState(error),useState(data)useEffect(() => { fetch(...).then(...).catch(...) })- Conditional rendering:
if (loading) return ...
Step 2: Extract Data Fetching to a Helper Function
Before moving to Suspense, extract the fetch logic:
// Before: fetch inside component
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); });
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
// Step 1: Extract to a helper
function fetchUser(userId) {
return fetch(`/api/users/${userId}`).then(r => r.json());
}
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId)
.then(data => { setUser(data); setLoading(false); });
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
This makes the next step (wrapping with Suspense) easier.
Step 3: Move Fetching Outside the Component
In Suspense, promises are created outside the component and passed in:
// Step 2: Move promise creation to parent
function UserProfile({ userPromise }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
userPromise
.then(data => { setUser(data); setLoading(false); });
}, [userPromise]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
function Page({ userId }) {
const userPromise = fetchUser(userId);
return <UserProfile userPromise={userPromise} />;
}
Now the promise is created in Page, not in UserProfile. The component still uses manual state, but the fetch is decoupled.
Step 4: Introduce Suspense and the use Hook
Replace manual state with use:
import { use, Suspense } from 'react';
// Step 3: Use the use hook and Suspense
function UserProfile({ userPromise }) {
const user = use(userPromise); // Replaces useState + useEffect
return <div>{user.name}</div>;
}
function Page({ userId }) {
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
The component is now half the size. The use hook replaces all manual state and effect logic.
Step 5: Add Error Handling with Error Boundary
Promises that reject need an Error Boundary:
import { use, Suspense } from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return <div>Error: {this.state.error.message}</div>;
}
return this.props.children;
}
}
function UserProfile({ userPromise }) {
const user = use(userPromise);
return <div>{user.name}</div>;
}
function Page({ userId }) {
const userPromise = fetchUser(userId);
return (
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
);
}
Now errors from rejected promises are caught and displayed.
Real Migration Example: A Multi-Component Page
Here's a more complex example: a page with a header, sidebar, and content, each fetching data:
Before (manual state everywhere):
// Before: Three components, three independent loading states
function PageHeader({ pageId }) {
const [header, setHeader] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/pages/${pageId}/header`)
.then(r => r.json())
.then(data => { setHeader(data); setLoading(false); });
}, [pageId]);
if (loading) return <div>Loading header...</div>;
return <h1>{header.title}</h1>;
}
function PageSidebar({ pageId }) {
const [sidebar, setSidebar] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/pages/${pageId}/sidebar`)
.then(r => r.json())
.then(data => { setSidebar(data); setLoading(false); });
}, [pageId]);
if (loading) return <div>Loading sidebar...</div>;
return <aside>{sidebar.content}</aside>;
}
function PageContent({ pageId }) {
const [content, setContent] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/pages/${pageId}/content`)
.then(r => r.json())
.then(data => { setContent(data); setLoading(false); });
}, [pageId]);
if (loading) return <div>Loading content...</div>;
return <main>{content.body}</main>;
}
export function Page({ pageId }) {
return (
<div>
<PageHeader pageId={pageId} />
<div style={{ display: 'flex' }}>
<PageSidebar pageId={pageId} />
<PageContent pageId={pageId} />
</div>
</div>
);
}
After (Suspense):
import { use, Suspense } from 'react';
// Clean, presentation-only components
function PageHeader({ headerPromise }) {
const header = use(headerPromise);
return <h1>{header.title}</h1>;
}
function PageSidebar({ sidebarPromise }) {
const sidebar = use(sidebarPromise);
return <aside>{sidebar.content}</aside>;
}
function PageContent({ contentPromise }) {
const content = use(contentPromise);
return <main>{content.body}</main>;
}
export function Page({ pageId }) {
// All fetches start immediately
const headerPromise = fetch(`/api/pages/${pageId}/header`).then(r => r.json());
const sidebarPromise = fetch(`/api/pages/${pageId}/sidebar`).then(r => r.json());
const contentPromise = fetch(`/api/pages/${pageId}/content`).then(r => r.json());
return (
<div>
<Suspense fallback={<div>Loading header...</div>}>
<PageHeader headerPromise={headerPromise} />
</Suspense>
<div style={{ display: 'flex' }}>
<Suspense fallback={<div>Loading sidebar...</div>}>
<PageSidebar sidebarPromise={sidebarPromise} />
</Suspense>
<Suspense fallback={<div>Loading content...</div>}>
<PageContent contentPromise={contentPromise} />
</Suspense>
</div>
</div>
);
}
Benefits:
- Less boilerplate: 45 lines → 35 lines (21% reduction).
- Parallel fetches: All three start immediately, not sequentially.
- Independent fallbacks: Each section shows its own skeleton.
- Easier testing: Components don't manage state; testing is just prop verification.
Testing Strategy During Migration
Use a wrapper component to test components before and after:
// Test wrapper for pre-migration testing
function UserProfileWrapper({ userId }) {
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
// Test it side-by-side with the old version
export function ComparisonTest() {
return (
<div style={{ display: 'flex', gap: '2rem' }}>
<div>
<h2>Old (useState)</h2>
<UserProfileOld userId={1} />
</div>
<div>
<h2>New (Suspense)</h2>
<UserProfileWrapper userId={1} />
</div>
</div>
);
}
Test side-by-side to verify both render identically, then gradually swap the old component for the new one.
Gradual Rollout Plan
- Week 1: Migrate 2–3 high-traffic components.
- Week 2: Migrate 5–10 supporting components; gather feedback.
- Week 3: Migrate remaining components.
- Week 4: Remove old boilerplate patterns; clean up.
Monitor error rates and performance metrics after each rollout.
Key Takeaways
- Extract fetching first: Move fetch logic to a helper before introducing Suspense.
- Move promises to parents: Components receive promises, not responsibility to create them.
- Use the
usehook: ReplaceuseState+useEffectwithuse+ Suspense boundary. - Add error boundaries: Always wrap Suspense with Error Boundary for complete error handling.
- Test incrementally: Compare old and new side-by-side before swapping.
Frequently Asked Questions
Can I migrate one component at a time?
Yes, but you'll need wrapper components. Use a wrapper that accepts a userId, creates the promise, and passes it to the Suspense-based component. This lets you migrate incrementally without touching parent components.
What if my component fetches based on user interaction?
Use startTransition and useState to create new promises on demand:
const [promise, setPromise] = useState(() => initialPromise);
function handleSearch(query) {
startTransition(() => {
setPromise(fetch(`/api/search?q=${query}`).then(r => r.json()));
});
}
How do I handle dependent fetches (fetch B after fetch A)?
Don't fetch in parallel. Create the second promise only after the first resolves:
function TwoDependentFetches({ userId }) {
const userPromise = fetch(`/api/users/${userId}`).then(r => r.json());
return (
<Suspense fallback={<div>Loading...</div>}>
<UserWithDependentData userPromise={userPromise} />
</Suspense>
);
}
function UserWithDependentData({ userPromise }) {
const user = use(userPromise);
// Now create the second promise with the user's ID
const companyPromise = fetch(`/api/companies/${user.companyId}`).then(r => r.json());
return (
<Suspense fallback={<div>Loading company...</div>}>
<Company companyPromise={companyPromise} />
</Suspense>
);
}
Do I need to update my testing library tests?
Yes, slightly. Test utilities like waitFor change:
// Before
render(<UserProfile userId={1} />);
await waitFor(() => expect(screen.getByText(/name:/)).toBeInTheDocument());
// After (with Suspense)
render(
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
await waitFor(() => expect(screen.getByText(/name:/)).toBeInTheDocument());
Wrap with Suspense in your test setup.
Can I use TanStack Query instead of raw fetch?
Yes. TanStack Query with Suspense mode works identically:
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
suspense: true,
});
const userName = user.name; // Synchronous