#Next.js#React

React Data Fetching: From fetch to React Query

`useEffect + fetch` looks simple, but it is full of traps: races, duplicate requests, no cache, and no retries. This article traces React data fetching from `useEffect` to React Query/SWR and Server Components, explaining when to use each and why RSC and RQ complement rather than replace each other.

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

React Data Fetching: From fetch to React Query

Most people's first React data-fetching code looks like this: fetch inside useEffect, followed by setState. It works, and may even look concise. But once you push it into production, it will bite you one night: racing requests, duplicate requests, no cache, no retries, flickering loading states, memory-leak warnings... These problems are not caused by you being insufficiently careful; the useEffect + fetch combination is itself an anti-pattern.

This article explains the evolution of React data fetching: from handwritten useEffect, to server-state libraries such as React Query/SWR, to server-side fetching with Server Components. Finally, I will show the combination pattern I use most often in practice.

1. useEffect + fetch: A Deceptively Simple Anti-Pattern

First, here is the textbook implementation:

tsx
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)
    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error('Failed')
        return res.json()
      })
      .then((data) => {
        if (!cancelled) {
          setUser(data)
          setError(null)
        }
      })
      .catch((err) => {
        if (!cancelled) setError(err)
      })
      .finally(() => {
        if (!cancelled) setLoading(false)
      })

    return () => {
      cancelled = true
    }
  }, [userId])

  if (loading) return <Spinner />
  if (error) return <ErrorView />
  return <UserCard user={user} />
}

To write this correctly, I have already written 30 lines. Yet it still has plenty of traps:

  • Race conditions: if userId changes from 1 to 2 in quick succession, what happens if request 2 returns before request 1? That cancelled flag exists to prevent exactly this, but many people do not write it at all, and the UI ends up displaying data from the old request.
  • Duplicate requests: if the same component mounts in several places, or users switch back and forth on a list page, it sends the request again every time. There is no caching at all.
  • No retries: one network hiccup produces an error, and users can only refresh manually.
  • No stale refresh: data may have been sitting on the screen for an hour while the backend changed long ago, with the UI completely unaware.
  • No update on window focus: users switch to another tab and come back, only to see stale data.
  • Manual loading/error management: all three states (loading / error / success) must be handwritten in every component, causing boilerplate to explode.

The root cause is this: useEffect is an escape hatch for synchronizing side effects with the outside world, not for fetching data. The React documentation now explicitly discourages this approach as well. What you need is an abstraction specifically designed to handle server state.

2. React Query / SWR: Server-State Libraries

React Query, now called TanStack Query, and SWR solve exactly these problems. Their core premise is: client state and server state are two different things, and they should not be managed with the same useState mechanism.

Server state has several characteristics: you do not own it because it belongs to a remote database; it can change at any time and become stale; it must be fetched asynchronously; and it needs caching and deduplication. Managing it with useState means reinventing an incomplete cache layer.

With React Query, those 30 lines become this:

tsx
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
    staleTime: 60_000,
  })

  if (isLoading) return <Spinner />
  if (error) return <ErrorView />
  return <UserCard user={user} />
}

With one useQuery call, you get all of this for free:

  • Automatic caching + request deduplication (only one concurrent request for the same query key)
  • Automatic retries (failed requests retry 3 times by default)
  • Stale refreshes (after staleTime, data is treated as stale and automatically refetched on window focus or remount)
  • The complete loading / error / success state set
  • Memory-leak protection (automatic cleanup when a component unmounts)

Someone once calculated that this one line of useQuery is equivalent to roughly 50 lines of handwritten logic. That is what choosing the right abstraction means.

3. RQ Core Concepts: query key, staleTime, invalidate

To use RQ well, first understand three concepts.

A Query Key is the unique identifier for a cache entry. It is an array and can be nested:

tsx
queryKey: ['users']                // 所有用户
queryKey: ['users', { page: 2 }]   // 第 2 页用户
queryKey: ['user', userId]         // 某个用户

RQ serializes and compares keys, so ['user', 1] and ['user', '1'] are different caches. Put every parameter that affects a request into the key. This is its form of dependency tracking, without requiring you to write a dependency array.

staleTime controls how long data is considered fresh before it becomes stale. The default is 0, which means it becomes stale the moment it is received. As a result, it is refetched by default whenever the window regains focus or the component remounts, although the cached value is returned first, so the experience still feels immediate. If your data does not change frequently, setting it to 60_000 (one minute) can substantially reduce unnecessary requests.

refetchOnWindowFocus is enabled by default. It may sound like a bug, but it is actually a product manager's dream feature: when users return, the data is automatically up to date. Disable it if you do not want that behavior.

invalidateQueries is the key to refreshing data after writes. After changing a username, you need to tell RQ that the cache is invalid and should be fetched again:

tsx
queryClient.invalidateQueries({ queryKey: ['user', userId] })

It marks matching queries as stale and refetches them in the background. Note that it uses pattern matching: invalidateQueries({ queryKey: ['users'] }) invalidates every query beginning with users, which is ideal for scenarios such as creating a user and refreshing both the list and detail views.

4. mutation: Writes and Optimistic Updates

Use useQuery for reads and useMutation for writes. A mutation looks like this:

tsx
const mutation = useMutation({
  mutationFn: (newName: string) =>
    fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify({ name: newName }),
    }),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['user', userId] })
  },
})

Calling mutation.mutate('Alice') triggers it. After success, the cache is invalidated and RQ automatically fetches the latest data. This is the safest pattern.

But sometimes you want the UI to feel faster: after clicking Save, users should immediately see the new name instead of waiting for server confirmation. That is an optimistic update: update the UI first, then confirm it, and roll it back on failure:

tsx
const mutation = useMutation({
  mutationFn: (newName: string) =>
    fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify({ name: newName }),
    }),
  onMutate: async (newName) => {
    // 取消正在进行的查询,避免覆盖我们的乐观值
    await queryClient.cancelQueries({ queryKey: ['user', userId] })
    // 快照旧值,用于回滚
    const prev = queryClient.getQueryData<User>(['user', userId])
    // 乐观写入新值
    queryClient.setQueryData<User>(['user', userId], (old) =>
      old ? { ...old, name: newName } : old,
    )
    return { prev }
  },
  onError: (_err, _newName, context) => {
    // 失败,回滚到旧值
    queryClient.setQueryData(['user', userId], context?.prev)
  },
  onSettled: () => {
    // 无论成败,后台重取拿服务端真实状态
    queryClient.invalidateQueries({ queryKey: ['user', userId] })
  },
})

The core of an optimistic update is onMutate (snapshot before the change + write the optimistic value) -> onError (rollback) -> onSettled (refetch and reconcile). Once you get comfortable with this pattern, your Save button will feel as fast as a local operation.

5. Server Components: Server-Side Fetching

With React Server Components (RSC), data fetching changes again. You await directly on the server:

tsx
// app/users/[id]/page.tsx
export default async function UserProfile({
  params,
}: {
  params: { id: string }
}) {
  const user = await fetch(`https://api.example.com/users/${params.id}`, {
    next: { revalidate: 60, tags: ['user'] },
  }).then((r) => r.json())

  return <UserCard user={user} />
}

This entire block runs on the server. The browser receives only rendered HTML; the fetch logic and the libraries it depends on have zero client-side JavaScript cost. As a side benefit, server rendering is naturally SEO-friendly, and the initial render no longer flickers through a loading state.

It is especially suitable for initial-view data: blog posts, product details, and the first page of a list. These are pieces of content that do not need to be triggered by interaction and are important for SEO.

6. RSC vs RQ: Not Replacements, but Complements

Many people, including my former self, have a misconception: once you have RSC, do you no longer need React Query? No. They solve different problems.

  • RSC handles initial-view, server-side data: it fetches and renders on the server with zero JavaScript cost. But it fetches only once at the moment of server rendering. What happens after user interaction? It does not refresh itself.
  • RQ handles dynamic client-side data: refreshing after a user mutation, automatically updating when users return to a tab, and sharing caches across pages. RSC cannot do these things.

Consider a concrete example: on a product detail page, use RSC to fetch product information for the initial view, which is fast and good for SEO. After a user clicks Add to Favorites, however, the favorite button state and cart badge need to update. Hand that part to client-side RQ with mutation + invalidate. One handles the static initial view; the other handles the dynamic experience after interaction. Each does its job.

7. Practical Pattern: Initial RSC + Interactive RQ + mutation

This is the combination I use most often in Next.js projects. Use it as-is:

tsx
// app/posts/page.tsx —— Server Component,首屏服务端取
import { getPosts } from '@/lib/api'
import { PostsList } from '@/components/PostsList'

export default async function Page() {
  const initialPosts = await getPosts() // 服务端取,零客户端 JS
  return <PostsList initialPosts={initialPosts} />
}
tsx
// components/PostsList.tsx —— Client Component,交互后用 RQ
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

export function PostsList({ initialPosts }: { initialPosts: Post[] }) {
  const queryClient = useQueryClient()

  // 用 initialData 把首屏数据"喂"给 RQ,避免二次请求
  const { data: posts } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: initialPosts,
    staleTime: 60_000,
  })

  // 写用 mutation + invalidate
  const mutation = useMutation({
    mutationFn: (title: string) => createPost(title),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    },
  })

  return (
    <>
      <button onClick={() => mutation.mutate('新文章')}>发文</button>
      {posts?.map((p) => <article key={p.id}>{p.title}</article>)}
    </>
  )
}

The benefits of this pattern are clear: a fast initial view (server-side fetching with no loading flicker), smooth interactions (RQ takes over afterward with automatic caching and refreshes), and clear write operations (mutation + invalidate handles the entire flow). initialData is the bridge between RSC and RQ: RSC passes its initial-view data to RQ as the starting point for the cache, and RQ continues the work.

8. Cache Strategy: Tune It by Data Freshness

RQ's default behavior is quite aggressive: staleTime = 0, meaning data becomes stale as soon as it arrives, so it is refetched on window focus. That creates too much request volume for many applications. I usually tune it like this:

  • High-frequency changes (message lists, notifications): staleTime: 0; keep the default and update frequently.
  • Medium frequency (user profiles, order lists): staleTime: 30_000 ~ 60_000; trust the cache for 30 seconds to 1 minute.
  • Low frequency (configuration, dictionary data, static content): staleTime: Infinity (or several hours); almost never refetch.
  • gcTime (old cache retention time, formerly cacheTime): the default is 5 minutes. After all observers for a query unmount, its cache remains for 5 minutes, so remounting within that period hits it directly. Increase it if low-frequency data should stay around longer.

The core principle of cache tuning is this: the amount of time you trust data should match its actual rate of change. Do not refetch a dictionary table that rarely changes every 30 seconds, and do not leave real-time notifications stale for five minutes.

Summary

  • useEffect + fetch is an anti-pattern: races, duplicate requests, no cache, no retries, and exploding boilerplate. In production, it will bite you sooner or later.
  • React Query / SWR separate server state from client state. One useQuery replaces 50 lines of handwritten logic, with caching, deduplication, retries, and stale refreshes handled automatically.
  • RQ's three essentials are queryKey (cache key), staleTime (freshness period), and invalidateQueries (refresh after writes). Use useMutation for writes, and optimistic updates when you need speed.
  • RSC handles initial views and server-side data with zero JavaScript cost and SEO benefits, while RQ handles dynamic data after interaction. They are complements, not replacements.
  • The practical golden combination is: fetch initial data with RSC -> feed it to RQ with initialData -> use mutation + invalidate for writes. Tune staleTime according to the data's actual rate of change.

Key point: Data fetching should not be a one-person show on either the client or the server. Give the initial view to the server, leave interaction to the client, and use the right tools rather than merely the right framework.

Related

Related Content