For years, React developers have danced a complicated tango with asynchronous data. We’ve wired up useEffect to trigger fetches, managed isLoading boolean flags like they were precious currency, and fought with race conditions when components unmounted too quickly.
If you are still writing code like const [data, setData] = useState(null), it’s time to stop.
By 2025, the landscape has shifted. With the stabilization of the use API, React has fundamentally changed how we handle resources—specifically Promises and Context—during the render phase. It’s cleaner, it integrates natively with Suspense, and it finally allows us to break some of the strict “Rules of Hooks” we’ve memorized for a decade.
In this guide, we aren’t just looking at the syntax. We are going to build a production-ready implementation, handle the tricky edge cases, and look at the architectural implications of adopting use.
Why use Matters Now
#
Before we touch the code, let’s understand the shift. The use hook (technically an API, not a standard hook, but let’s not split hairs) allows you to read the value of a resource like a Promise or Context directly within the render function.
Crucially, use can be called conditionally. This breaks the rigid top-level rule of standard hooks like useState or useContext.
What you will learn:
- How to strip out
useEffectfetching logic entirely. - Handling async flows with Suspense boundaries.
- Consuming Context inside loops and conditionals.
- Avoiding the “Waterfall of Death” in async rendering.
Environment Setup #
To follow along, you need a modern React environment. As of 2025/2026, standard Create React App is long dead. We assume you are using Vite or a framework like Next.js/Remix that supports the latest React features.
Prerequisites:
- Node.js v20+
- React 19+
Let’s initialize a quick sandbox:
npm create vite@latest react-use-demo -- --template react-ts
cd react-use-demo
npm install
npm run devPart 1: Unwrapping Promises without useEffect
#
The old way of fetching data involved a “fetch-on-render” or “fetch-on-effect” strategy that often led to layout shifts and complex state management. With use, we shift to a “render-as-you-fetch” or simply “suspend-on-render” mental model.
When you pass a Promise to use, React checks if it’s resolved:
- Resolved: It returns the data.
- Rejected: It throws the error (caught by Error Boundary).
- Pending: It suspends the component (caught by Suspense).
The Architecture of Suspending #
Here is how the flow works visually. Note how the component effectively “pauses” execution without us manually returning a null or <Spinner />.
The Implementation #
Let’s create a simulated API service and a component that consumes it.
src/api.ts
// Simulating a backend delay
export type User = {
id: number;
name: string;
email: string;
};
// We need to cache the promise so we don't recreate it on every render.
// In real apps, libraries like TanStack Query or React 'cache' do this.
const userCache = new Map<number, Promise<User>>();
export const fetchUser = (id: number): Promise<User> => {
if (!userCache.has(id)) {
const promise = new Promise<User>(async (resolve, reject) => {
// Simulate network latency
await new Promise(r => setTimeout(r, 1500));
if (id < 0) reject(new Error("Invalid User ID"));
resolve({
id,
name: `User_${id}`,
email: `user${id}@devpro.io`
});
});
userCache.set(id, promise);
}
return userCache.get(id)!;
};src/components/UserProfile.tsx
import { use } from 'react';
import { fetchUser } from '../api';
// Note: The prop here is the PROMISE, not the ID.
// This facilitates the "Render-as-you-fetch" pattern.
interface UserProfileProps {
userPromise: Promise<any>;
}
export default function UserProfile({ userPromise }: UserProfileProps) {
// ONE LINE. No useEffect, no useState, no if(loading).
const user = use(userPromise);
return (
<div className="p-6 max-w-sm mx-auto bg-white rounded-xl shadow-md flex items-center space-x-4 border border-gray-100">
<div className="flex-shrink-0">
<div className="h-12 w-12 bg-indigo-500 rounded-full flex items-center justify-center text-white font-bold">
{user.name[0]}
</div>
</div>
<div>
<div className="text-xl font-medium text-black">{user.name}</div>
<p className="text-gray-500">{user.email}</p>
</div>
</div>
);
}src/App.tsx
import { Suspense, useState } from 'react';
import UserProfile from './components/UserProfile';
import { fetchUser } from './api';
// Initiate fetch OUTSIDE render or in an event handler to avoid loops
// Ideally, this happens in a router loader.
const initialUserPromise = fetchUser(1);
export default function App() {
const [userPromise, setUserPromise] = useState(initialUserPromise);
const handleNextUser = () => {
// We set the PROMISE into state, triggering a re-render
// The child will read this new promise and suspend automatically.
const nextId = Math.floor(Math.random() * 100) + 1;
setUserPromise(fetchUser(nextId));
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center gap-6">
<h1 className="text-3xl font-bold text-gray-800">React 'use' Demo</h1>
{/* The Boundary is required! */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
<button
onClick={handleNextUser}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 transition"
>
Load Random User
</button>
</div>
);
}
// Simple fallback UI
function UserSkeleton() {
return (
<div className="p-6 w-full max-w-sm mx-auto bg-white rounded-xl shadow-md flex items-center space-x-4 animate-pulse">
<div className="rounded-full bg-slate-200 h-12 w-12"></div>
<div className="flex-1 space-y-4 py-1">
<div className="h-4 bg-slate-200 rounded w-3/4"></div>
<div className="h-4 bg-slate-200 rounded w-1/2"></div>
</div>
</div>
);
}Important Observation #
Notice we are passing the Promise object itself as a prop. This is a crucial pattern shift. If you create the promise inside the component function without memoization, you will create an infinite loop of fetching and re-rendering.
Part 2: Context in Conditionals #
The second superpower of use is reading Context conditionally.
React’s strict rule “Don’t call Hooks inside loops, conditions, or nested functions” exists because React relies on the call order to track hook state. However, use is different. It doesn’t rely on the linked-list index of hooks in the same way useState does.
Scenario: You have a list of items. Only some items need to access a heavy Context (like a Theme or Auth context), or you want to access context inside a if block.
import { createContext, use, useState } from 'react';
const ThemeContext = createContext('light');
function ListItem({ item, important }: { item: string, important: boolean }) {
let theme = 'light';
// CONDITIONAL USAGE - Previously impossible
if (important) {
theme = use(ThemeContext);
}
const className = important
? (theme === 'dark' ? 'bg-red-900 text-white' : 'bg-red-100 text-red-800')
: 'text-gray-600';
return (
<li className={`p-2 rounded ${className}`}>
{item} {important && `(Theme: ${theme})`}
</li>
);
}This flexibility allows for cleaner code separation. You don’t need to hook into the Context at the top of the component if you only need it in one specific branch of logic.
Comparison: Old vs New #
Let’s break down the tangible differences between the legacy approach and the use API.
| Feature | useEffect / useContext (Legacy) |
use API (Modern) |
Impact |
|---|---|---|---|
| Data Fetching | Requires useEffect + useState + isLoading. |
Pass Promise to use(promise). |
50% less boilerplate code. |
| Loading State | Manual conditional rendering (if (loading)...). |
Declarative Suspense boundary. |
Decouples UI logic from fetching logic. |
| Context | Must be top-level. No conditionals. | Can be used inside if, for, switch. |
Performance optimization (early returns before context read). |
| Error Handling | try/catch inside effect, local state error. |
Error Boundaries (componentDidCatch). |
Errors bubble up naturally like sync code. |
| Server Components | await works natively (RSC). |
use bridges async logic in Client Components. |
Unifies mental model between Client and Server. |
Performance Pitfalls: The Waterfall #
While use is powerful, it makes it very easy to accidentally create “request waterfalls.”
If you have two use(promise) calls in sequence, the second one won’t start until the first one resolves (because the component suspends at the first line).
The Wrong Way (Sequential Blocking) #
function BadDashboard({ userPromise, postsPromise }) {
// 🛑 The component suspends here...
const user = use(userPromise);
// ...and postsPromise is only read/triggered after user resolves!
// (Assuming promises are cold or created inside render, which is bad practice anyway)
const posts = use(postsPromise);
return <Layout user={user} posts={posts} />;
}The Right Way (Parallel Execution) #
Always initiate your promises as early as possible (Lift to parents, Router Loaders, or Promise.all).
function GoodDashboard({ dataPromise }) {
// ✅ Wait for both to finish in parallel
const [user, posts] = use(dataPromise);
return <Layout user={user} posts={posts} />;
}
// Somewhere in a parent:
const dataPromise = Promise.all([fetchUser(), fetchPosts()]);Error Handling is Not Optional #
When you move away from useEffect, you lose the ability to simple console.error inside a .catch() block and set a local error state variable to show a message.
Because use integrates with Suspense, rejected promises operate like thrown errors in synchronous JavaScript. They bubble up the component tree until they hit an Error Boundary.
If you don’t wrap your Suspense components in an Error Boundary, a single failed API call will crash your entire application (White Screen of Death).
Standard generic Error Boundary:
import { Component, ReactNode } from "react";
class ErrorBoundary extends Component<{children: ReactNode, fallback: ReactNode}, {hasError: boolean}> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.fallback;
}
return this.props.children;
}
}Note: In 2025, while the React team has discussed functional error boundaries, class components or libraries like react-error-boundary remain the standard implementation mechanism.
Conclusion #
The use API is the missing link that finally aligns the asynchronous nature of the web with the synchronous nature of React’s render cycle. It eliminates the “effect spaghetti” that has plagued codebases for years and pushes us toward a cleaner, boundary-based architecture.
Key Takeaways:
- Stop using
useEffectfor data fetching. It creates race conditions and waterfalls. - Memoize your Promises.
usedoes not cache data for you. You need a caching layer (like TanStack Query) or careful caching of the Promise object itself. - Embrace Boundaries. Design your UI in chunks of loading states (Suspense) and failure states (Error Boundaries).
The transition might feel strange at first—passing promises as props feels very “2010”—but the result is a React application that is more resilient, faster to render, and significantly easier to read.
Further Reading #
- React Docs:
useAPI Reference - RFC: First Class Support for Promises
- “The WET Codebase” - Why caching layers are essential with
use.
Found this deep dive helpful? Subscribe to React DevPro for more architectural patterns and React internals analysis.