Fetching in Client Components
Server Components are the default and best place to fetch data in the App Router, but plenty of legitimate cases still need data fetching to happen in the browser — things like polling, search-as-you-type, or data that depends on client-only state such as scroll position or a browser API. This page covers how to fetch from a Client Component, and why you should almost always reach for a data-fetching library instead of raw useEffect.
The useEffect + useState approach
This is the classic pattern from pre-Server-Components React: track loading, error, and data manually, and fire the request inside a useEffect.
'use client'
import { useEffect, useState } from 'react'
interface Comment {
id: string
text: string
}
export default function CommentsWidget({ postId }: { postId: string }) {
const [comments, setComments] = useState<Comment[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/api/posts/${postId}/comments`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) setComments(data)
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
return () => {
cancelled = true
}
}, [postId])
if (error) return <p>Failed to load comments.</p>
if (!comments) return <p>Loading comments…</p>
return (
<ul>
{comments.map((comment) => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
)
}Prefer a dedicated data-fetching library
Libraries like SWR and TanStack Query (React Query) exist specifically to solve client-side fetching well: caching by key, automatic revalidation, deduplication of concurrent requests, retries, and built-in loading/error state — all with far less code than a hand-rolled useEffect.
'use client'
import useSWR from 'swr'
interface Comment {
id: string
text: string
}
const fetcher = (url: string) => fetch(url).then((res) => res.json())
export default function CommentsWidget({ postId }: { postId: string }) {
const { data, error, isLoading } = useSWR<Comment[]>(
`/api/posts/${postId}/comments`,
fetcher,
)
if (error) return <p>Failed to load comments.</p>
if (isLoading) return <p>Loading comments…</p>
return (
<ul>
{data?.map((comment) => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
)
}SWR keys its cache by the URL (or key array) you pass in, so if two components request the same key at the same time, only one network request actually goes out. It also automatically revalidates on window focus and network reconnect by default, keeping data fresh without any extra code.
When client-side fetching still makes sense
Data that depends on client-only state or interaction, like a search box that fetches results as the user types.
Data that needs to poll or refresh on an interval while the user has a tab open, such as a live status widget.
Data scoped to something that only exists in the browser, like the current viewport size or a value from localStorage.
Highly interactive widgets where you want optimistic UI updates driven from the client, independent of the page’s own render lifecycle.
Server Components vs. Client Components for fetching
Scenario | Recommended approach |
|---|---|
Data needed to render the initial page | Fetch in a Server Component |
Data behind a database or private API key | Fetch in a Server Component (keeps secrets off the client) |
Data that changes based on user typing/interaction | Fetch in a Client Component with SWR/React Query |
Data that needs to poll periodically | Fetch in a Client Component with SWR/React Query |