Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Aug 27, 2026·13 min read

Server State Is Not UI State: React Query, SWR & the RSC Boundary

Server state is asynchronous, remote, stale-by-default, and the UI doesn't own it. Treating it like local state — with useState and useEffect — is the root cause of race conditions, stale data, and duplicated loading spinners. This article covers TanStack Query, SWR, optimistic updates, and the Next.js RSC/Server Actions model.

Server State Is Not UI State: React Query, SWR & the RSC Boundary

The second pillar of senior-level state thinking is segregate domains. The most consequential segregation in modern React applications is between two categories that look superficially similar but require fundamentally different tools: UI state and server state.
UI state is synchronous, local, and owned by the application. Server state is asynchronous, remote, stale-by-default, and owned by the backend. Treating server state like UI state — storing it in useState and fetching it with useEffect — is the root cause of race conditions, stale data, duplicated loading spinners, and waterfall requests that make applications feel slow.

1. What Makes Server State Different

Server state has properties that UI state never does:
PropertyUI StateServer State
OwnershipYour applicationThe backend / database
SynchronySynchronousAsynchronous
FreshnessAlways currentStale by default
SharingLocal to this tabPotentially shared with other users
Error surfaceUnlikelyNetwork failure, auth expiry, 500s
CachingNot neededCritical
InvalidationN/ARequired after mutations
The moment you accept that server state is a different category, the right tools become obvious: you need a caching layer, a revalidation strategy, and a way to express async status — none of which useState provides.

2. The useState + useEffect Fetch Pattern — A Post-Mortem

This is the pattern most developers write first:
typescript
// ❌ The classic pattern — has at least four structural problems
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    setIsLoading(true)
    setError(null)
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data)
        setIsLoading(false)
      })
      .catch((err) => {
        setError(err)
        setIsLoading(false)
      })
  }, [userId])

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />
  if (!user) return null
  return <UserCard user={user} />
}
Problem 1: Race conditions. If userId changes from 'A' to 'B' while the fetch for 'A' is still in flight, both responses will call setUser. The slower response wins — the UI ends up displaying data for the wrong user.
Problem 2: No request deduplication. If three components on the page all need the same user, three parallel requests fire. There is no shared cache.
Problem 3: No background revalidation. The user navigates away and comes back 5 minutes later. The data is stale but the component shows it without refetching.
Problem 4: No cache. Every mount fires a new network request, even if the data was fetched seconds ago on a previous render.
Timeline sequence diagram illustrating the race condition. Two horizontal fetch timelines shown in parallel. Timeline A (userId=A): fetch starts at t=0ms, response arrives at t=500ms. Timeline B (userId=B): userId changes at t=100ms, new fetch starts, response arrives at t=200ms. At t=200ms a vertical marker shows 'setUser(dataB) — UI shows correct user B'. At t=500ms a second marker shows 'setUser(dataA) — UI silently reverts to stale user A'. The t=500ms section is highlighted red with label 'BUG: slower request wins, wrong user displayed'. A caption reads: 'Without an AbortController, an in-flight fetch for a stale userId can overwrite the result of a faster, more recent fetch'.
Figure: Timeline sequence diagram illustrating the race condition. Two horizontal fetch timelines shown in parallel. Timeline A (userId=A): fetch starts at t=0ms, response arrives at t=500ms. Timeline B (userId=B): userId changes at t=100ms, new fetch starts, response arrives at t=200ms. At t=200ms a vertical marker shows 'setUser(dataB) — UI shows correct user B'. At t=500ms a second marker shows 'setUser(dataA) — UI silently reverts to stale user A'. The t=500ms section is highlighted red with label 'BUG: slower request wins, wrong user displayed'. A caption reads: 'Without an AbortController, an in-flight fetch for a stale userId can overwrite the result of a faster, more recent fetch'.

3. TanStack Query (React Query): The Correct Tool

TanStack Query treats server state as a first-class concern. It provides a cache keyed by queryKey, handles the async lifecycle, deduplicates in-flight requests, and revalidates data in the background.
typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// ✅ Same functionality as above — but correct
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, isError, error } = useQuery({
    queryKey: ['users', userId],   // cache address — unique per userId
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000,     // treat as fresh for 5 minutes
  })

  if (isLoading) return <Spinner />
  if (isError) return <ErrorMessage error={error} />
  if (!user) return null
  return <UserCard user={user} />
}
Every problem from the useEffect pattern is resolved:
  • Race condition: React Query uses an internal abort controller. When userId changes, the previous request's response is discarded.
  • Deduplication: Three components using useQuery({ queryKey: ['users', '123'] }) fire exactly one request.
  • Background revalidation: When the component remounts after staleTime has elapsed, React Query refetches in the background while serving the cached data — the user sees content immediately.
  • Cache: The result is stored and served instantly on subsequent mounts within staleTime.

3.1 QueryKey Design — The Cache Address

The queryKey is a serialized cache address. Its design determines how granular your invalidation can be:
typescript
// Good queryKey design — from broad to specific
queryKey: ['users']                         // all users
queryKey: ['users', userId]                 // one specific user
queryKey: ['users', userId, 'posts']        // posts belonging to that user
queryKey: ['users', userId, 'posts', { filter: 'published' }]  // filtered subset
Crucial Requirement
Design your queryKey hierarchy the way you'd design a REST URL — broad category first, then identifiers, then filters. When you invalidateQueries({ queryKey: ['users', userId] }), it invalidates the user and all their nested queries (posts, comments, etc.) in one call. A flat queryKey loses this granularity.

3.2 Stale-While-Revalidate Strategy

typescript
useQuery({
  queryKey: ['dashboard-stats'],
  queryFn: fetchDashboardStats,
  staleTime: 30_000,       // serve from cache for 30 seconds without revalidating
  gcTime: 5 * 60_000,      // keep in cache for 5 minutes after last subscriber unmounts
  refetchOnWindowFocus: true,  // revalidate when user returns to the tab
})
The stale-while-revalidate model: serve stale data immediately (fast UX), then refetch in the background and update. The user never sees a loading spinner for already-cached data.
Horizontal timeline diagram of React Query's cache lifecycle. Time axis from t=0 to t=10min. At t=0: 'Component mounts — cache miss — fetch fires — status: loading' (blue). At t=300ms: 'Data arrives, cached, status: success' (green band begins). A green band labeled 'staleTime window (fresh, 5 min)' spans t=300ms to t=5min. After t=5min: band turns yellow labeled 'Data stale — served immediately from cache on next mount'. At t=5min+remount: 'Cache HIT — stale data served instantly (no spinner) + background refetch fires simultaneously' (two arrows). At t=5min+300ms: 'Fresh data arrives, UI updates'. Below the timeline: 'gcTime (5 min after last subscriber unmounts): entry removed from cache entirely'. Caption: 'stale-while-revalidate: show cached data immediately for instant UX, revalidate in background for correctness'.
Figure: Horizontal timeline diagram of React Query's cache lifecycle. Time axis from t=0 to t=10min. At t=0: 'Component mounts — cache miss — fetch fires — status: loading' (blue). At t=300ms: 'Data arrives, cached, status: success' (green band begins). A green band labeled 'staleTime window (fresh, 5 min)' spans t=300ms to t=5min. After t=5min: band turns yellow labeled 'Data stale — served immediately from cache on next mount'. At t=5min+remount: 'Cache HIT — stale data served instantly (no spinner) + background refetch fires simultaneously' (two arrows). At t=5min+300ms: 'Fresh data arrives, UI updates'. Below the timeline: 'gcTime (5 min after last subscriber unmounts): entry removed from cache entirely'. Caption: 'stale-while-revalidate: show cached data immediately for instant UX, revalidate in background for correctness'.

4. Mutations and Cache Invalidation

typescript
const queryClient = useQueryClient()

const updateUserMutation = useMutation({
  mutationFn: (updates: Partial<User>) => patchUser(userId, updates),

  // After a successful mutation, invalidate the user query so it refetches
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['users', userId] })
  },
})

// In the component
<button onClick={() => updateUserMutation.mutate({ name: 'Bob' })}>
  Save
</button>
invalidateQueries marks the cache entry as stale and triggers a background refetch if there are active subscribers. This is the correct way to keep the UI consistent after mutations — not manually calling setUser(updatedUser).

5. Optimistic Updates

Optimistic UI updates the local cache before the server confirms the mutation, then rolls back on failure. This makes mutations feel instantaneous.
typescript
const toggleLikeMutation = useMutation({
  mutationFn: (postId: string) => togglePostLike(postId),

  onMutate: async (postId) => {
    // 1. Cancel any in-flight queries for this post (prevent overwrites)
    await queryClient.cancelQueries({ queryKey: ['posts', postId] })

    // 2. Snapshot the current cache value (for rollback)
    const previousPost = queryClient.getQueryData<Post>(['posts', postId])

    // 3. Apply the optimistic update
    queryClient.setQueryData<Post>(['posts', postId], (old) => ({
      ...old!,
      likedByMe: !old!.likedByMe,
      likeCount: old!.likedByMe ? old!.likeCount - 1 : old!.likeCount + 1,
    }))

    return { previousPost }  // passed to onError as context
  },

  onError: (_err, postId, context) => {
    // 4. Roll back to the snapshot on failure
    if (context?.previousPost) {
      queryClient.setQueryData(['posts', postId], context.previousPost)
    }
  },

  onSettled: (_, __, postId) => {
    // 5. Always revalidate after mutation (success or failure) to sync server truth
    queryClient.invalidateQueries({ queryKey: ['posts', postId] })
  },
})

6. SWR — Lightweight Alternative

SWR (stale-while-revalidate) by Vercel covers the same core use case with a simpler API and smaller bundle:
typescript
import useSWR, { mutate } from 'swr'

const fetcher = (url: string) => fetch(url).then((r) => r.json())

function UserProfile({ userId }: { userId: string }) {
  const { data: user, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher, {
    revalidateOnFocus: true,
    dedupingInterval: 2000,  // deduplicate requests within 2 seconds
  })

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

// Invalidate by key
mutate(`/api/users/${userId}`)
When to choose SWR over React Query:
  • Simpler key structure (URL strings vs. array-based keys)
  • Lighter bundle weight matters
  • No need for mutations, complex invalidation, or optimistic updates
When to choose React Query over SWR:
  • Complex mutation lifecycle (optimistic updates, rollback)
  • Hierarchical cache invalidation
  • Infinite queries / pagination
  • DevTools for debugging cache state

7. Next.js App Router & React Server Components

The RSC model solves a fundamentally different problem: eliminating the client-server waterfall entirely by fetching data on the server before any JavaScript ships to the browser.
tsx
// app/users/[id]/page.tsx — React Server Component
// This is an async function that runs on the server — no useEffect, no loading state
export default async function UserPage({ params }: { params: { id: string } }) {
  // Direct async/await — no hook, no client-side fetch
  const user = await fetchUser(params.id)

  // This component ships ZERO JavaScript to the client — it's HTML
  return (
    <main>
      <UserHeader user={user} />
      {/* Server Component can compose Client Components */}
      <InteractiveActions userId={user.id} />
    </main>
  )
}
The Server/Client boundary is explicit: Server Components fetch data, Client Components handle browser interactivity.
tsx
// components/InteractiveActions.tsx — Client Component
'use client'

import { useState } from 'react'

// This component ships JavaScript — it has client-side interactivity
export function InteractiveActions({ userId }: { userId: string }) {
  const [isFollowing, setIsFollowing] = useState(false)
  return (
    <button onClick={() => setIsFollowing((f) => !f)}>
      {isFollowing ? 'Unfollow' : 'Follow'}
    </button>
  )
}
Pro Tip & Optimization
Push the 'use client' boundary as far down the tree as possible. Server Components don't ship JavaScript, don't need hydration, and can directly access databases, filesystems, and secrets. Keeping the server/client split at leaf-level interactive elements maximizes performance.
Side-by-side architecture comparison labeled 'Client Waterfall vs React Server Components'. Left panel 'Traditional SPA': four sequential steps with downward arrows — (1) Browser requests page, receives JS bundle; (2) React renders skeleton with spinner; (3) useEffect fires, sends fetch /api/user; (4) Data arrives, second render with content. A stopwatch shows '~600ms to content'. Right panel 'React Server Component': two steps — (1) Server fetches data and renders HTML in a single async function; (2) Browser receives fully populated HTML with zero client-side fetch. A stopwatch shows '~200ms to content'. A highlighted callout: 'Server Components ship zero JavaScript to the browser'. Caption: 'RSCs eliminate the client-server waterfall by co-locating data fetching with rendering on the server'.
Figure: Side-by-side architecture comparison labeled 'Client Waterfall vs React Server Components'. Left panel 'Traditional SPA': four sequential steps with downward arrows — (1) Browser requests page, receives JS bundle; (2) React renders skeleton with spinner; (3) useEffect fires, sends fetch /api/user; (4) Data arrives, second render with content. A stopwatch shows '~600ms to content'. Right panel 'React Server Component': two steps — (1) Server fetches data and renders HTML in a single async function; (2) Browser receives fully populated HTML with zero client-side fetch. A stopwatch shows '~200ms to content'. A highlighted callout: 'Server Components ship zero JavaScript to the browser'. Caption: 'RSCs eliminate the client-server waterfall by co-locating data fetching with rendering on the server'.

8. Server Actions: The Mutation Path in RSC

Server Actions close the mutation loop for RSC applications:
typescript
// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function updateUserAction(userId: string, data: Partial<User>) {
  await db.users.update({ where: { id: userId }, data })

  // Revalidate the cached page that renders this user
  revalidatePath(`/users/${userId}`)
}
tsx
// app/users/[id]/edit/page.tsx
'use client'
import { updateUserAction } from '@/app/actions'

export function EditUserForm({ user }: { user: User }) {
  return (
    <form action={updateUserAction.bind(null, user.id)}>
      <input name="name" defaultValue={user.name} />
      <button type="submit">Save</button>
    </form>
  )
}
revalidatePath purges the Next.js Data Cache for the specified route, triggering a fresh server-side fetch on the next request. No queryClient.invalidateQueries needed — the revalidation happens on the server, close to the data.

9. References

  1. TanStack Query — Overview
  2. SWR — Data Fetching
  3. Next.js — Data Fetching and Caching
  4. Next.js — Server Actions
  5. React — Server Components
  6. Practical React Query — TkDodo's Blog
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#React#React Query#TanStack Query#SWR#Server State#Data Fetching#Next.js#React Server Components#Server Actions#Caching
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.