Introduction
The use hook is one of React's most significant additions, providing a first-class way to read asynchronous resources during render. Unlike useState or useEffect, use can be called conditionally and inside loops, making it uniquely flexible for data fetching patterns. When combined with Suspense, use creates a declarative model where components simply "read" data, and React handles the loading states automatically.
This guide explores the use hook in depth—from its fundamental mechanics to advanced patterns for caching, error handling, and concurrent data fetching. You'll learn how use differs from useEffect-based fetching, how to integrate it with popular data libraries, and how to build production-ready data fetching patterns. Whether you're building a simple blog or a complex dashboard, understanding use will transform how you think about data in React.
Understanding the use() Hook: Core Concepts
What is use()?
The use hook is a special React hook that "unwraps" a value from a Promise or Context. Unlike other hooks, it can be called conditionally and inside loops—it doesn't follow the Rules of Hooks because it doesn't maintain state between renders.
import { use } from 'react'
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Suspends if promise is pending
return <h1>{user.name}</h1>
}
function ThemeDisplay() {
const theme = use(ThemeContext) // Reads context
return <div className={theme}>Current theme: {theme}</div>
}When use encounters a pending promise, it tells React to suspend rendering. React then shows the nearest Suspense boundary's fallback. When the promise resolves, React re-renders the component with the resolved value.
use() vs useEffect for Data Fetching
The traditional useEffect approach requires managing loading states manually:
// Traditional: useEffect + useState
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetchUser(userId)
.then(data => { if (!cancelled) setUser(data) })
.catch(err => { if (!cancelled) setError(err) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [userId])
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <div>{user!.name}</div>
}
// New: use() + Suspense
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <div>{user.name}</div>
}
// Parent handles loading
function App() {
const userPromise = fetchUser('123')
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}The use approach separates concerns: the component focuses on rendering, and the parent decides the loading experience.
Conditional Data Fetching
A unique feature of use is that it can be called conditionally. This enables patterns that are impossible with other hooks:
function UserOrGuest({ isLoggedIn, userId }: Props) {
if (isLoggedIn) {
// Only fetch user data if logged in
const user = use(fetchUser(userId))
return <h1>Welcome back, {user.name}</h1>
}
return <h1>Welcome, Guest</h1>
}
// Dynamic data based on selection
function DataViewer({ type }: { type: 'users' | 'posts' }) {
let data
if (type === 'users') {
data = use(fetchUsers())
} else {
data = use(fetchPosts())
}
return <List items={data} />
}Architecture and Design Patterns
Promise Cache Pattern
To avoid refetching data on every render, you need a cache that stores promises by key.
class PromiseCache {
private cache = new Map<string, {
promise: Promise<unknown>
status: 'pending' | 'resolved' | 'rejected'
value?: unknown
error?: unknown
}>()
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const existing = this.cache.get(key)
if (existing) {
if (existing.status === 'resolved') {
return Promise.resolve(existing.value as T)
}
if (existing.status === 'rejected') {
return Promise.reject(existing.error)
}
return existing.promise as Promise<T>
}
const entry = {
promise: fetcher().then(
(value) => {
entry.status = 'resolved'
entry.value = value
return value
},
(error) => {
entry.status = 'rejected'
entry.error = error
throw error
}
),
status: 'pending' as const,
}
this.cache.set(key, entry)
return entry.promise
}
invalidate(key: string) {
this.cache.delete(key)
}
invalidateAll() {
this.cache.clear()
}
}
export const promiseCache = new PromiseCache()Integration with React Cache
React 18 introduced the cache function for request-level memoization. Combined with use, this creates efficient server-side data fetching:
import { cache } from 'react'
// Server Component data fetching with cache
const getUser = cache(async (id: string) => {
const response = await fetch(`https://api.example.com/users/${id}`)
return response.json()
})
// Server Component
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)
return <div>{user.name}</div>
}
// Multiple components can call getUser(userId) without duplicate fetches
async function UserPage({ userId }: { userId: string }) {
return (
<div>
<UserProfile userId={userId} />
<UserPosts userId={userId} /> {/* Also calls getUser */}
</div>
)
}Error Handling Architecture
The use hook works with Error Boundaries for declarative error handling:
class DataErrorBoundary extends React.Component<
{ fallback: (error: Error, reset: () => void) => React.ReactNode; children: React.ReactNode },
{ error: Error | null }
> {
state = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, () => {
this.setState({ error: null })
})
}
return this.props.children
}
}
// Usage
function App() {
return (
<DataErrorBoundary
fallback={(error, reset) => (
<div>
<h2>Error: {error.message}</h2>
<button onClick={reset}>Try Again</button>
</div>
)}
>
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
</DataErrorBoundary>
)
}Step-by-Step Implementation
Building a useQuery Hook
Create a production-ready data fetching hook that integrates with use and Suspense:
import { use, useCallback, useMemo, useRef } from 'react'
interface UseQueryOptions<T> {
queryKey: string[]
queryFn: () => Promise<T>
staleTime?: number
retryCount?: number
enabled?: boolean
}
interface QueryEntry<T> {
promise: Promise<T> | null
data: T | undefined
error: Error | undefined
status: 'idle' | 'pending' | 'error' | 'success'
lastUpdated: number
}
// Global query cache
const queryCache = new Map<string, QueryEntry<any>>()
function getQueryEntry<T>(key: string): QueryEntry<T> {
if (!queryCache.has(key)) {
queryCache.set(key, {
promise: null,
data: undefined,
error: undefined,
status: 'idle',
lastUpdated: 0,
})
}
return queryCache.get(key) as QueryEntry<T>
}
export function useQuery<T>({
queryKey,
queryFn,
staleTime = 30_000,
retryCount = 3,
enabled = true,
}: UseQueryOptions<T>): { data: T } {
const key = useMemo(() => queryKey.join('-'), [queryKey])
const entry = getQueryEntry<T>(key)
if (!enabled) {
if (entry.data !== undefined) {
return { data: entry.data }
}
throw new Promise(() => {}) // Suspend indefinitely
}
const isStale = Date.now() - entry.lastUpdated > staleTime
if (entry.status === 'success' && !isStale) {
return { data: entry.data! }
}
if (entry.status === 'error' && entry.error) {
throw entry.error
}
if (!entry.promise || isStale) {
let retries = 0
const fetchWithRetry = async (): Promise<T> => {
try {
const data = await queryFn()
entry.data = data
entry.status = 'success'
entry.lastUpdated = Date.now()
entry.promise = null
return data
} catch (error) {
if (retries < retryCount) {
retries++
await new Promise(r => setTimeout(r, 1000 * retries))
return fetchWithRetry()
}
entry.error = error as Error
entry.status = 'error'
entry.promise = null
throw error
}
}
entry.promise = fetchWithRetry()
entry.status = 'pending'
}
throw entry.promise
}Using the useQuery Hook
function UserList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 60_000,
})
return (
<ul>
{users.map(user => (
<li key={user.id}>
<UserCard user={user} />
</li>
))}
</ul>
)
}
function UserCard({ user }: { user: User }) {
const { data: posts } = useQuery({
queryKey: ['user-posts', user.id],
queryFn: () => fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
staleTime: 30_000,
})
return (
<div>
<h3>{user.name}</h3>
<p>{posts.length} posts</p>
</div>
)
}
// App with Suspense boundaries
function App() {
return (
<ErrorBoundary fallback={<GlobalError />}>
<Suspense fallback={<AppSkeleton />}>
<UserList />
</Suspense>
</ErrorBoundary>
)
}Prefetching with use()
import { use, startTransition } from 'react'
function prefetchUser(userId: string) {
const key = `user-${userId}`
if (!queryCache.has(key)) {
queryCache.set(key, {
promise: fetchUser(userId),
data: undefined,
error: undefined,
status: 'pending',
lastUpdated: 0,
})
}
}
// Prefetch on hover
function UserLink({ userId, name }: { userId: string; name: string }) {
return (
<Link
to={`/users/${userId}`}
onMouseEnter={() => prefetchUser(userId)}
onFocus={() => prefetchUser(userId)}
>
{name}
</Link>
)
}
// User page reads from cache
function UserPage({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}Real-World Use Cases
Use Case 1: Dashboard with Multiple Data Sources
A dashboard that loads user data, analytics, and notifications independently, each with its own Suspense boundary:
function Dashboard() {
return (
<div className="dashboard">
<ErrorBoundary fallback={<HeaderError />}>
<Suspense fallback={<HeaderSkeleton />}>
<UserHeader />
</Suspense>
</ErrorBoundary>
<div className="dashboard-grid">
<ErrorBoundary fallback={<ChartError />}>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<ChartError />}>
<Suspense fallback={<ChartSkeleton />}>
<UserMetrics />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<ListError />}>
<Suspense fallback={<ListSkeleton />}>
<RecentActivity />
</Suspense>
</ErrorBoundary>
</div>
</div>
)
}
function RevenueChart() {
const { data } = useQuery({
queryKey: ['revenue', 'monthly'],
queryFn: () => fetch('/api/analytics/revenue').then(r => r.json()),
})
return <Chart data={data} type="line" />
}
function RecentActivity() {
const { data } = useQuery({
queryKey: ['activity', 'recent'],
queryFn: () => fetch('/api/activity?limit=10').then(r => r.json()),
})
return (
<ul>
{data.map(activity => (
<ActivityItem key={activity.id} activity={activity} />
))}
</ul>
)
}Use Case 2: Search with Debounced Fetching
function SearchPage() {
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [isPending, startTransition] = useTransition()
useEffect(() => {
const timer = setTimeout(() => {
startTransition(() => {
setDebouncedQuery(query)
})
}, 300)
return () => clearTimeout(timer)
}, [query])
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{debouncedQuery && (
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<ErrorBoundary fallback={<SearchError />}>
<Suspense fallback={<SearchSkeleton />}>
<SearchResults query={debouncedQuery} />
</Suspense>
</ErrorBoundary>
</div>
)}
</div>
)
}
function SearchResults({ query }: { query: string }) {
const { data } = useQuery({
queryKey: ['search', query],
queryFn: () =>
fetch(`/api/search?q=${encodeURIComponent(query)}`).then(r => r.json()),
})
return (
<ul>
{data.map(result => (
<SearchResultCard key={result.id} result={result} />
))}
</ul>
)
}Use Case 3: Dependent Queries
function UserWithPosts({ userId }: { userId: string }) {
// First query: get user
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
// Second query depends on the first
// This only runs after user data is available
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!user,
})
return (
<div>
<h1>{user.name}</h1>
<h2>Posts ({posts.length})</h2>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
)
}Best Practices for Production
-
Cache keys should be deterministic: Use arrays of strings/numbers as cache keys. Avoid objects—serialization is expensive and error-prone.
-
Set appropriate stale times: Short stale times (5-30s) for frequently changing data. Long stale times (minutes) for rarely changing data. Zero stale time for real-time data.
-
Implement cache invalidation: After mutations, invalidate related queries. Use query client utilities to invalidate by prefix.
-
Use Error Boundaries at the right granularity: Too high means losing the entire UI on one failure. Too low means many boundaries to manage.
-
Prefetch on user intent: Don't prefetch everything. Prefetch when the user shows intent—hovering a link, focusing an input, or entering a route.
-
Handle race conditions: When the user rapidly changes inputs (typing in search), ensure only the latest request's data is used. Libraries like React Query handle this automatically.
-
Combine with transitions for smooth UX: Use
useTransitionto keep showing current data while new data loads, preventing loading spinners on every change. -
Monitor cache size: In long-running applications, implement cache eviction. React Query does this with
cacheTime; implement similar logic in custom caches.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Calling use() outside Suspense | App crashes | Always wrap use() components in Suspense |
| No cache key stability | Refetch on every render | Use stable keys (not inline objects) |
| Stale data after mutations | Users see outdated data | Invalidate related cache entries after mutations |
| Unhandled promise rejection | Silent failures | Always pair use() with Error Boundary |
| Conditional use() with hooks | Rules of Hooks violation | use() is exempt—other hooks still need rules |
| Cache pollution in SSR | Server data leaks to wrong user | Use request-scoped caches in SSR |
Performance Optimization
// Prefetch multiple queries in parallel
function prefetchDashboardData() {
return Promise.all([
promiseCache.get('users', fetchUsers),
promiseCache.get('revenue', fetchRevenue),
promiseCache.get('activity', fetchActivity),
])
}
// Use with router transitions
function DashboardLink() {
const navigate = useNavigate()
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
// Prefetch data before navigating
prefetchDashboardData().then(() => {
navigate('/dashboard')
})
}
return <Link to="/dashboard" onClick={handleClick}>Dashboard</Link>
}
// Selective caching based on data volatility
function useRealtimeQuery<T>(key: string[], fetcher: () => Promise<T>) {
return useQuery({
queryKey: key,
queryFn: fetcher,
staleTime: 0, // Always refetch
})
}
function useStaticQuery<T>(key: string[], fetcher: () => Promise<T>) {
return useQuery({
queryKey: key,
queryFn: fetcher,
staleTime: Infinity, // Never refetch automatically
})
}Comparison with Alternatives
| Feature | use() + Suspense | React Query | SWR | useEffect |
|---|---|---|---|---|
| Conditional fetching | Yes | Yes | Yes | Manual |
| Suspense integration | Native | Yes | Yes | No |
| Caching | Manual | Automatic | Automatic | Manual |
| Background refetch | No | Yes | Yes | No |
| Retry logic | Manual | Built-in | Built-in | Manual |
| DevTools | React DevTools | Dedicated | None | React DevTools |
| Bundle size | 0 KB | 13 KB | 4.3 KB | 0 KB |
| Learning curve | Medium | Low | Low | Low |
When to use use() directly:
- Building a framework or library
- You need conditional data fetching
- Server Components with
cache - Maximum control over caching strategy
When to use React Query/SWR:
- Production applications with standard data fetching patterns
- You need automatic cache management, retries, and background refetching
- Team prefers a battle-tested library over custom caching logic
Advanced Patterns and Techniques
Streaming with use()
// Server Component with streaming
async function ProductPage({ id }: { id: string }) {
const productPromise = fetchProduct(id)
const reviewsPromise = fetchReviews(id)
return (
<div>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productPromise={productPromise} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsList reviewsPromise={reviewsPromise} />
</Suspense>
</div>
)
}
// Client component reads promise via use()
function ProductDetails({ productPromise }: { productPromise: Promise<Product> }) {
const product = use(productPromise)
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
</div>
)
}Infinite Scrolling with use()
function InfiniteList() {
const [page, setPage] = useState(1)
return (
<div>
{Array.from({ length: page }, (_, i) => (
<Suspense key={i} fallback={<PageSkeleton />}>
<PageContent page={i + 1} />
</Suspense>
))}
<button onClick={() => setPage(p => p + 1)}>Load More</button>
</div>
)
}
function PageContent({ page }: { page: number }) {
const { data } = useQuery({
queryKey: ['items', page],
queryFn: () => fetch(`/api/items?page=${page}`).then(r => r.json()),
})
return (
<>
{data.items.map(item => (
<ItemCard key={item.id} item={item} />
))}
</>
)
}Optimistic Updates
function TodoList() {
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
async function addTodo(text: string) {
// Optimistically add to cache
queryCache.set('todos', {
...getQueryEntry('todos'),
data: [...todos, { id: 'temp', text, completed: false }],
})
try {
await createTodo({ text })
// Invalidate to get real data
queryCache.delete('todos')
} catch (error) {
// Revert on error
queryCache.delete('todos')
}
}
return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
<AddTodoForm onAdd={addTodo} />
</div>
)
}Testing Strategies
import { render, screen, waitFor } from '@testing-library/react'
import { Suspense } from 'react'
test('use() hook loads data', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ name: 'John' }),
})
render(
<ErrorBoundary fallback={<div>Error</div>}>
<Suspense fallback={<div>Loading</div>}>
<UserProfile userPromise={fetchUser('1')} />
</Suspense>
</ErrorBoundary>
)
expect(screen.getByText('Loading')).toBeInTheDocument()
expect(await screen.findByText('John')).toBeInTheDocument()
})
test('use() hook handles errors', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'))
render(
<ErrorBoundary fallback={({ error }) => <div>{error.message}</div>}>
<Suspense fallback={<div>Loading</div>}>
<UserProfile userPromise={fetchUser('1')} />
</Suspense>
</ErrorBoundary>
)
expect(await screen.findByText('Network error')).toBeInTheDocument()
})Future Outlook
The use hook is part of React's broader vision for data fetching. Combined with Server Components, streaming SSR, and Suspense, it creates a unified model where data flows naturally through the component tree. The React team is working on improvements to concurrent data fetching, better cache primitives, and deeper integration with frameworks like Next.js and Remix.
As the ecosystem matures, expect libraries like React Query to build on top of use, providing familiar APIs with the power of Suspense. Understanding use now positions you to leverage these tools as they evolve.
Conclusion
The use hook represents React's declarative approach to data fetching. By allowing components to "read" promises during render, it eliminates the complexity of useEffect-based data fetching and integrates naturally with Suspense for loading states.
Key takeaways:
useunwraps promises and context during render, suspending if data isn't ready- Conditional data fetching is possible because
usedoesn't follow Rules of Hooks - Pair with Suspense for loading states and Error Boundaries for error handling
- Cache promises to avoid refetching on every render
- Prefetch on user intent for instant navigation experiences
- Use libraries like React Query for production applications with standard patterns
Start by adopting use for simple data fetching in new components, then progressively build caching and error handling patterns as your application grows. The combination of use, Suspense, and Error Boundaries creates a robust, maintainable data fetching architecture that scales with your application.