Skip to main content
Browse all
WOWHOW/FIELD NOTES/DEVELOPER-PRODUCTIVITY·21 MAY 2026·15 MIN READ

useEffect for data fetching is an anti-pattern in React 19. Server Components, async components, cache(), use(), and Suspense streaming handle every fetch scenario with less code and better performance.

WW
WOWHOW
FOUNDER · 14YR SHIPPING
Published
21 May 2026
Reading
15 min · 2,000 words
TL;DR

React 19 Server Components eliminate useEffect for data fetching. Learn 7 patterns: async components, parallel fetching, Suspense streaming, cache(), and the use() hook.

If you are still writing useEffect(() => { fetch(...).then(setData) }, []), you are working against React 19. Server Components changed the data fetching model fundamentally. The waterfall of loading states, empty renders, and double-fetch problems that plagued React 16-18 apps are architectural — they come from fetching data in client components — and Server Components eliminate the architecture that caused them.

This guide covers 7 data fetching patterns in React 19. Each one replaces a specific use case where you previously reached for useEffect. All examples use Next.js 16 App Router, which ships React 19 by default.

For production-ready React components and starter kits, see our developer tools catalog.

Try it yourself: Free JSON Formatter & Validator — free, no signup, runs in your browser.

Pattern 1: Async Server Components (The Default)

The simplest replacement for useEffect data fetching: make the component async and await the data directly. No state, no loading check, no effect.

// app/products/page.tsx — Server Component (default in App Router)
import { getProducts } from '@/lib/api'

export default async function ProductsPage() {
  // Fetched on the server, streamed to client — no client-side fetch, no loading state
  const products = await getProducts()

  return (
    <main>
      <h1>Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <a href={`/products/${product.slug}`}>{product.name}</a>
          </li>
        ))}
      </ul>
    </main>
  )
}

// lib/api.ts
export async function getProducts() {
  const res = await fetch('https://api.wowhow.cloud/products', {
    next: { revalidate: 3600 },  // ISR: revalidate every hour
  })
  if (!res.ok) throw new Error('Failed to fetch products')
  return res.json()
}

Pattern 2: Parallel Data Fetching (No Waterfall)

The classic useEffect waterfall: fetch user, then fetch their orders. Each await waits for the previous before starting. The fix: launch all requests in parallel with Promise.all.

// app/dashboard/page.tsx
import { getUser, getOrders, getNotifications } from '@/lib/api'

export default async function Dashboard({ params }: { params: Promise<{ userId: string }> }) {
  const { userId } = await params

  // All three start simultaneously — total time = slowest request, not sum of all
  const [user, orders, notifications] = await Promise.all([
    getUser(userId),
    getOrders(userId),
    getNotifications(userId),
  ])

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <OrderList orders={orders} />
      <NotificationBell count={notifications.unread} />
    </div>
  )
}

Pattern 3: Suspense for Streaming — Show Fast, Load Slow

Wrap slow components in Suspense to stream the page progressively. The fast parts render immediately; the slow parts stream in as their data resolves.

// app/product/[slug]/page.tsx
import { Suspense } from 'react'
import { ProductDetails } from '@/components/ProductDetails'
import { ProductReviews } from '@/components/ProductReviews'
import { RelatedProducts } from '@/components/RelatedProducts'
import { Skeleton } from '@/components/ui/Skeleton'

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params

  return (
    <div>
      {/* Renders immediately — fast data */}
      <Suspense fallback={<Skeleton className="h-96" />}>
        <ProductDetails slug={slug} />
      </Suspense>

      {/* Streams in separately — slow data, isolated loading state */}
      <Suspense fallback={<Skeleton className="h-48" />}>
        <ProductReviews slug={slug} />
      </Suspense>

      {/* Independent loading — doesn't block product details */}
      <Suspense fallback={<Skeleton className="h-64" />}>
        <RelatedProducts slug={slug} />
      </Suspense>
    </div>
  )
}

// components/ProductReviews.tsx — async server component
async function ProductReviews({ slug }: { slug: string }) {
  const reviews = await getProductReviews(slug)  // can be slow — isolated by Suspense
  return (
    <section>
      <h2>Reviews ({reviews.length})</h2>
      {reviews.map(r => <ReviewCard key={r.id} review={r} />)}
    </section>
  )
}

Pattern 4: React cache() for Deduplication

cache() from React deduplicates identical requests within a single render pass. When multiple server components on the same page call getUser(userId), the function only executes once — the result is shared.

// lib/data/user.ts
import { cache } from 'react'
import { db } from '@/lib/db'

// Wrap expensive database queries in cache()
export const getUserById = cache(async (userId: string) => {
  console.log(`[DB] Fetching user ${userId}`)  // logs only once per request, even if called 5 times
  return db.users.findUnique({ where: { id: userId } })
})

// lib/data/products.ts
export const getProductBySlug = cache(async (slug: string) => {
  return db.products.findUnique({
    where: { slug },
    include: { category: true, images: true },
  })
})

// Now multiple components can call getUserById(userId) safely
// The DB query runs once per request cycle — not once per component

Pattern 5: use() Hook for Client Components That Need Server Data

When a Client Component needs data, pass a Promise from a Server Component and unwrap it with the use() hook. Suspense handles the loading state automatically.

// app/cart/page.tsx — Server Component passes promise to client
import { Suspense } from 'react'
import { CartClient } from '@/components/CartClient'
import { getCart } from '@/lib/data/cart'

export default async function CartPage() {
  // Start the fetch — do NOT await yet
  const cartPromise = getCart()

  return (
    <Suspense fallback={<div>Loading cart...</div>}>
      <CartClient cartPromise={cartPromise} />
    </Suspense>
  )
}

// components/CartClient.tsx — 'use client' component
'use client'
import { use } from 'react'
import type { Cart } from '@/types'

interface Props {
  cartPromise: Promise<Cart>
}

export function CartClient({ cartPromise }: Props) {
  // use() suspends the component until the promise resolves
  // No useState, no useEffect, no loading state
  const cart = use(cartPromise)

  return (
    <div>
      {cart.items.map(item => (
        <CartItem key={item.id} item={item} />
      ))}
      <CartTotal total={cart.total} />
    </div>
  )
}

Pattern 6: Error Boundaries for Graceful Failures

Async Server Components throw on error — wrap them with an Error Boundary to show a fallback instead of crashing the whole page.

// app/error.tsx — Next.js error boundary for route segments
'use client'

interface ErrorPageProps {
  error: Error & { digest?: string }
  reset: () => void
}

export default function ErrorPage({ error, reset }: ErrorPageProps) {
  return (
    <div role="alert" className="p-8 text-center">
      <h2>Something went wrong</h2>
      <p className="text-sm text-gray-500">{error.message}</p>
      <button onClick={reset} className="mt-4 btn-primary">
        Try again
      </button>
    </div>
  )
}

// For component-level error isolation, use react-error-boundary:
import { ErrorBoundary } from 'react-error-boundary'

function ProductPageWithBoundary({ slug }: { slug: string }) {
  return (
    <ErrorBoundary
      fallback={<p>Failed to load product reviews.</p>}
    >
      <Suspense fallback={<Skeleton />}>
        <ProductReviews slug={slug} />
      </Suspense>
    </ErrorBoundary>
  )
}

Pattern 7: Server Actions for Mutations

Data mutations (form submissions, button clicks that write data) use Server Actions in React 19. No API route needed — the function runs on the server and the client calls it directly.

// app/actions/cart.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { getAuthUser } from '@/lib/auth'

export async function addToCart(productId: string, quantity: number) {
  const user = await getAuthUser()
  if (!user) throw new Error('Must be logged in to add to cart')

  await db.cartItems.upsert({
    where: { userId_productId: { userId: user.id, productId } },
    update: { quantity: { increment: quantity } },
    create: { userId: user.id, productId, quantity },
  })

  revalidatePath('/cart')  // trigger ISR revalidation for the cart page
}

// components/AddToCartButton.tsx — client component
'use client'
import { useTransition } from 'react'
import { addToCart } from '@/app/actions/cart'

export function AddToCartButton({ productId }: { productId: string }) {
  const [isPending, startTransition] = useTransition()

  function handleClick() {
    startTransition(async () => {
      await addToCart(productId, 1)
    })
  }

  return (
    <button onClick={handleClick} disabled={isPending} className="btn-primary">
      {isPending ? 'Adding...' : 'Add to Cart'}
    </button>
  )
}

People Also Ask

Do I still need useEffect in React 19?

Yes, but for a much narrower set of use cases. useEffect is still appropriate for: subscribing to external stores (WebSockets, third-party event emitters), syncing with non-React systems (DOM manipulation, analytics), and cleanup on unmount. Data fetching, which was the #1 use of useEffect, should now use Server Components, cache(), or the use() hook instead. The React team recommends treating useEffect as an escape hatch, not a first-class data tool.

When should I use cache() vs fetch() with next.revalidate?

Use cache() from React for database queries and non-HTTP data sources — it deduplicates within a request. Use fetch() with next: { revalidate: N } for HTTP calls where you also want ISR (stale-while-revalidate behaviour between requests). The two work together: wrap your fetch() call in cache() to get both deduplication within a request AND revalidation across requests.

Can Server Components read cookies or headers?

Yes. Import cookies() and headers() from next/headers — both are async in Next.js 16. Calling either opts the component out of static rendering and into dynamic rendering, so use them only where you genuinely need per-request data (auth checks, locale detection). For pages that can be cached, do auth checks in middleware instead and pass user context via headers.

Tags:React 19Server Componentsdata fetchingSuspenseuse hookcache()Server ActionsNext.jsuseEffect replacement
All Articles
WW

Written by

WOWHOW

The WOWHOW team brings 14+ years of production engineering experience. Every tool and product in the catalog is personally built, tested, and curated.

Monday Memo · Free

One insight, every Monday. 7am IST. Zero fluff.

1 field report, 3 links, 1 tool we actually use. No fluff, no spam.

Need production-ready templates?

Free browser tools with no signup, plus 2,000+ premium dev templates and starter kits.

Comments · 0

Beta: comments are stored locally on your device and not visible to other readers.

Sign in to join the conversation

No comments yet. Be the first to share your thoughts.

Pairs with this note

More from developer-productivity

See all