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 (
Products
{products.map((product) => (
{product.name}
))}
)
}
// 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 }) {
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 (
Welcome, {user.name}
)
}
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
}) {
const { slug } = await params
return (
{/* Renders immediately — fast data */}
}>
{/* Streams in separately — slow data, isolated loading state */}
}>
{/* Independent loading — doesn't block product details */}
}>
)
}
// components/ProductReviews.tsx — async server component
async function ProductReviews({ slug }: { slug: string }) {
const reviews = await getProductReviews(slug) // can be slow — isolated by Suspense
return (
Reviews ({reviews.length})
{reviews.map(r => )}
)
}
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 (
Loading cart...}>
)
}
// components/CartClient.tsx — 'use client' component
'use client'
import { use } from 'react'
import type { Cart } from '@/types'
interface Props {
cartPromise: Promise
}
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 (
{cart.items.map(item => (
))}
)
}
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 (
Something went wrong
{error.message}
Try again
)
}
// For component-level error isolation, use react-error-boundary:
import { ErrorBoundary } from 'react-error-boundary'
function ProductPageWithBoundary({ slug }: { slug: string }) {
return (
}>
)
}
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 (
{isPending ? 'Adding...' : 'Add to Cart'}
)
}
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.
Originally published at wowhow.cloud
Top comments (0)