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.
Comments · 0
No comments yet. Be the first to share your thoughts.