Skip to main content

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:

  1. High-impact components: Components that fetch data and are used in many places (pages, modals, widgets).
  2. Simple fetches: Components that fetch one or two data sources (more complex multi-source fetches require careful planning).
  3. 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

  1. Week 1: Migrate 2–3 high-traffic components.
  2. Week 2: Migrate 5–10 supporting components; gather feedback.
  3. Week 3: Migrate remaining components.
  4. 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 use hook: Replace useState + useEffect with use + 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

Further Reading