logic into a dedicated hook. This creates a stable contract and prevents scattered cleanup code from leaking into UI components.
import { useState, useEffect } from 'react';
interface InventoryReport {
sku: string;
quantity: number;
warehouse: string;
lastUpdated: string;
}
async function fetchInventoryReport(warehouseId: string): Promise<InventoryReport[]> {
const response = await fetch(`/api/inventory/${warehouseId}`);
if (!response.ok) throw new Error(`Failed to fetch inventory for ${warehouseId}`);
return response.json();
}
export function useLegacyInventory(warehouseId: string) {
const [report, setReport] = useState<InventoryReport[] | null>(null);
const [isPending, setIsPending] = useState(true);
const [failure, setFailure] = useState<Error | null>(null);
useEffect(() => {
let aborted = false;
setIsPending(true);
setFailure(null);
fetchInventoryReport(warehouseId)
.then((data) => {
if (!aborted) setReport(data);
})
.catch((err) => {
if (!aborted) setFailure(err instanceof Error ? err : new Error(String(err)));
})
.finally(() => {
if (!aborted) setIsPending(false);
});
return () => {
aborted = true;
};
}, [warehouseId]);
return { report, isPending, failure };
}
Rationale: Encapsulation prevents UI components from managing network lifecycles. The aborted flag mitigates state updates on unmounted components, a common source of memory leaks and React warnings. This hook now serves as a migration boundary.
Step 2: Introduce Suspense and Error Boundaries
Wrap the consuming components with Suspense and Error Boundaries. This step is safe to execute immediately, even while the underlying hook still uses useEffect. The boundaries will not trigger until the data flow switches to promise suspension, but establishing them early prevents UI fragmentation later.
import { Suspense, ErrorBoundary } from 'react';
import { InventoryDashboard } from './InventoryDashboard';
import { SkeletonGrid } from './SkeletonGrid';
import { NetworkErrorFallback } from './NetworkErrorFallback';
export function WarehouseView({ warehouseId }: { warehouseId: string }) {
return (
<ErrorBoundary fallback={<NetworkErrorFallback context="inventory" />}>
<Suspense fallback={<SkeletonGrid rows={4} columns={3} />}>
<InventoryDashboard warehouseId={warehouseId} />
</Suspense>
</ErrorBoundary>
);
}
Rationale: Suspense and Error Boundaries are structural primitives, not rendering triggers. Adding them early decouples UI fallbacks from component logic. When the internal hook eventually switches to use(), the boundaries will automatically intercept pending states and rejections without requiring component refactoring.
Step 3: Swap Internals to use()
Replace the effect-driven hook with a promise-based consumer. The component now receives a promise and delegates state management to React's suspension mechanism.
import { use } from 'react';
interface InventoryDashboardProps {
inventoryPromise: Promise<InventoryReport[]>;
}
export function InventoryDashboard({ inventoryPromise }: InventoryDashboardProps) {
const report = use(inventoryPromise);
return (
<div className="grid grid-cols-3 gap-4">
{report.map((item) => (
<div key={item.sku} className="p-4 border rounded">
<h3>{item.sku}</h3>
<p>Stock: {item.quantity}</p>
<p>Location: {item.warehouse}</p>
<p className="text-sm text-gray-500">Updated: {item.lastUpdated}</p>
</div>
))}
</div>
);
}
Rationale: use() unwraps the promise, suspends the component tree until resolution, and throws on rejection. This eliminates manual isPending and failure state entirely. The component becomes a pure projection of data. Notice that the parent component (WarehouseView) now passes a promise instead of an ID. This is intentional and leads to the final architectural shift.
Step 4: Lift Promise Creation
Move promise instantiation out of the component tree. Promises should be created in parent components, routing layers, or Server Components. This ensures stable references, prevents recreation on re-renders, and enables React's caching mechanisms.
// Server Component or Parent Route Handler
import { InventoryDashboard } from './InventoryDashboard';
export async function WarehouseRoute({ params }: { params: { id: string } }) {
const inventoryPromise = fetch(`/api/inventory/${params.id}`).then((res) => {
if (!res.ok) throw new Error('Inventory fetch failed');
return res.json();
});
return <InventoryDashboard inventoryPromise={inventoryPromise} />;
}
Rationale: Creating promises inside components causes suspension thrashing and breaks React's memoization. Lifting creation to a stable scope guarantees the promise reference remains constant across renders. When combined with Server Components, this enables streaming HTML, automatic cache integration, and zero client-side waterfalls.
Pitfall Guide
Migrating to use() introduces new failure modes if developers apply old mental models. The following pitfalls are frequently encountered in production environments.
1. Creating Promises Inside Render
Explanation: Instantiating a promise directly in a component body causes it to be recreated on every render. React treats each new promise as a distinct dependency, triggering infinite suspension loops.
Fix: Always lift promise creation to a parent scope, route handler, or use React.cache() / external cache libraries. Pass the stable reference down as a prop.
2. Mixing use() with useEffect for the Same Data
Explanation: Developers sometimes keep an effect for side effects while using use() for data. This creates duplicate execution paths and breaks suspension guarantees.
Fix: Separate concerns. Use use() strictly for data dependencies. Use useEffect only for synchronization with external systems (e.g., DOM manipulation, third-party SDKs). Never fetch data in both places.
3. Omitting Error Boundaries
Explanation: use() throws on promise rejection. Without an Error Boundary, the rejection propagates up the tree and crashes the entire application.
Fix: Always wrap use() consumers in <ErrorBoundary>. Design fallback UIs that allow retry or graceful degradation. Never rely on try/catch inside components for promise handling.
4. Passing Unstable Promise References
Explanation: If a parent component recreates the promise on every render (e.g., via inline arrow functions or missing memoization), child components will re-suspend unnecessarily, causing UI flicker and wasted network requests.
Fix: Memoize promise creation using useMemo, route-level initialization, or Server Components. Verify reference stability with Object.is() during debugging.
5. Overusing use() in Deeply Nested Trees
Explanation: Placing use() in multiple deeply nested components without granular Suspense boundaries blocks the entire subtree until all promises resolve. This negates streaming benefits.
Fix: Co-locate Suspense boundaries near data dependencies. Use multiple <Suspense> wrappers to allow partial rendering. Prioritize streaming critical UI while deferring secondary data.
6. Assuming use() Handles Caching Automatically
Explanation: use() only unwraps promises. It does not cache, deduplicate, or invalidate requests. Re-rendering a component with a new promise instance triggers a fresh network call.
Fix: Integrate with React Cache (React.cache()), SWR, TanStack Query, or a custom memoization layer. Ensure promises are shared across components requesting the same resource.
7. Ignoring startTransition for Non-Critical Data
Explanation: Suspending on high-priority updates (e.g., form inputs) causes UI jank. React's concurrent scheduler needs hints to deprioritize data fetching.
Fix: Wrap promise creation or route transitions in startTransition() when the data is not immediately visible. This keeps the UI responsive while background fetching occurs.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small internal tool with low traffic | use() + inline promise creation in parent | Simplicity outweighs caching complexity | Low (minimal infrastructure) |
| Large legacy SPA with scattered effects | Incremental hook encapsulation β boundary injection β use() swap | Prevents regression while modernizing data flow | Medium (engineering time for migration) |
| SSR/Streaming application | Server Component promise creation β use() in client components | Enables HTML streaming, reduces client waterfalls | High (requires framework alignment) |
| Real-time dashboard with frequent updates | use() + WebSocket/SSE + React Cache | Maintains suspension model while handling live data | Medium (cache invalidation logic) |
Configuration Template
A production-ready Suspense/Error boundary composition with retry capability and loading granularity.
import { Suspense, ErrorBoundary, startTransition } from 'react';
interface DataBoundaryProps {
promise: Promise<unknown>;
fallback: React.ReactNode;
errorFallback: React.ReactNode;
children: (data: unknown) => React.ReactNode;
}
export function DataBoundary({ promise, fallback, errorFallback, children }: DataBoundaryProps) {
return (
<ErrorBoundary fallback={errorFallback}>
<Suspense fallback={fallback}>
<DataRenderer promise={promise} children={children} />
</Suspense>
</ErrorBoundary>
);
}
function DataRenderer({ promise, children }: { promise: Promise<unknown>; children: (data: unknown) => React.ReactNode }) {
const data = use(promise);
return <>{children(data)}</>;
}
// Usage wrapper for non-blocking transitions
export function withTransition<T>(fn: () => Promise<T>): () => Promise<T> {
return () => {
return new Promise((resolve) => {
startTransition(() => {
fn().then(resolve);
});
});
};
}
Quick Start Guide
- Identify a target component currently using
useEffect for data fetching. Extract the fetch logic into a standalone function that returns a promise.
- Wrap the component with
<ErrorBoundary> and <Suspense>. Provide fallback UI for loading and error states.
- Refactor the component to accept a promise prop. Replace all
useState/useEffect data handling with const data = use(promise).
- Lift promise creation to the nearest stable parent or route handler. Ensure the promise reference is memoized or generated server-side.
- Validate using React DevTools Profiler. Confirm no re-suspension on re-renders, verify error boundaries catch failures, and measure initial paint improvement.