MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React use() Hook: Data Fetching Patterns

Master React's use() hook: integration with Suspense, caching, and error boundaries.

Reactuse()Data FetchingFrontend

By MinhVo

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.

React use Hook

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.

Data Fetching Flow

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>
  )
}

Integration Architecture

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

  1. Cache keys should be deterministic: Use arrays of strings/numbers as cache keys. Avoid objects—serialization is expensive and error-prone.

  2. 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.

  3. Implement cache invalidation: After mutations, invalidate related queries. Use query client utilities to invalidate by prefix.

  4. Use Error Boundaries at the right granularity: Too high means losing the entire UI on one failure. Too low means many boundaries to manage.

  5. Prefetch on user intent: Don't prefetch everything. Prefetch when the user shows intent—hovering a link, focusing an input, or entering a route.

  6. 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.

  7. Combine with transitions for smooth UX: Use useTransition to keep showing current data while new data loads, preventing loading spinners on every change.

  8. 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

PitfallImpactSolution
Calling use() outside SuspenseApp crashesAlways wrap use() components in Suspense
No cache key stabilityRefetch on every renderUse stable keys (not inline objects)
Stale data after mutationsUsers see outdated dataInvalidate related cache entries after mutations
Unhandled promise rejectionSilent failuresAlways pair use() with Error Boundary
Conditional use() with hooksRules of Hooks violationuse() is exempt—other hooks still need rules
Cache pollution in SSRServer data leaks to wrong userUse 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

Featureuse() + SuspenseReact QuerySWRuseEffect
Conditional fetchingYesYesYesManual
Suspense integrationNativeYesYesNo
CachingManualAutomaticAutomaticManual
Background refetchNoYesYesNo
Retry logicManualBuilt-inBuilt-inManual
DevToolsReact DevToolsDedicatedNoneReact DevTools
Bundle size0 KB13 KB4.3 KB0 KB
Learning curveMediumLowLowLow

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:

  1. use unwraps promises and context during render, suspending if data isn't ready
  2. Conditional data fetching is possible because use doesn't follow Rules of Hooks
  3. Pair with Suspense for loading states and Error Boundaries for error handling
  4. Cache promises to avoid refetching on every render
  5. Prefetch on user intent for instant navigation experiences
  6. 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.