Server State and Data Fetching
Why API responses don't belong in Redux, and what the browser's caching layer actually looks like
Learning Objectives
By the end of this module you will be able to:
- Articulate why server state requires different treatment from local UI state, and what breaks when API responses are put into a Redux store.
- Explain the stale-while-revalidate strategy and how query keys drive cache invalidation in TanStack Query.
- Compare CSR, SSR, ISR, and streaming SSR as rendering strategies, describing the latency tradeoffs of each.
- Implement an optimistic update with rollback using TanStack Query's mutation pattern.
- Identify and eliminate client-side fetch waterfalls using parallel fetching or server-side data loading.
Server State Is a Different Animal
If you've worked on backend systems, you already understand caching, invalidation, and eventual consistency. But you've likely thought about these from the server's perspective — managing what gets written to Redis, when to bust a memcached key, how to reason about stale reads from a replica.
Server state on the frontend is the same problem in a different seat.
Server state is data that lives remotely, can be modified by other users or processes at any time, goes stale the moment it lands in the browser, and needs explicit mechanisms to stay fresh. This stands in sharp contrast to client state — things like whether a modal is open, which tab is selected, or what a user has typed into a form. Client state belongs to you and is always current. Server state is borrowed and degrades.
The practical consequence is that they need different management strategies. Modern frontend architecture explicitly distinguishes between client state (UI state) and server state (fetched data), managing them with dedicated tools rather than lumping everything together.
A common mistake is storing API responses in a Redux store. This pattern treats server state as if it were client state: it puts data that is inherently remote, shared, and stale into a container designed for deterministic, locally-owned state. You immediately inherit a set of problems Redux was never designed to solve: when do you refetch? How do you invalidate after a mutation? What happens to the cached value when two components mount simultaneously and both trigger a fetch? Dedicated server state libraries handle all of this. Redux should hold application-level state — authenticated user info, theme, cart contents — not API responses.
Stale-While-Revalidate
The stale-while-revalidate pattern is the conceptual core of how libraries like TanStack Query and SWR work. When data is requested:
- If a cached value exists, it is served immediately — even if it may be stale.
- A background revalidation request is fired.
- When fresh data arrives, the UI updates.
This means users see content instantly, without a loading spinner, and the UI quietly catches up. The tradeoff is eventual freshness rather than guaranteed freshness: for a brief window, users may see data that has changed on the server.
Stale-while-revalidate serves cached content immediately while revalidating in the background, balancing speed with eventual freshness.
SWR by Vercel is named directly after this HTTP caching directive. TanStack Query (formerly React Query) implements the same semantics with more advanced configuration options.
Query Keys as the Cache Invalidation Mechanism
In TanStack Query, every query has a query key — an array that uniquely identifies it and serves as the cache key. This is the primary invalidation mechanism.
// A query for a specific user's todos
useQuery({ queryKey: ['todos', userId], queryFn: fetchTodos })
// After a mutation, invalidate everything matching this prefix
queryClient.invalidateQueries({ queryKey: ['todos'] })
Query key factories provide centralized, maintainable structures so you don't scatter magic arrays across your codebase. The pattern enables pattern-based cache busting: you can invalidate by exact match, by prefix, or by arbitrary predicate.
When a query is invalidated and the component using it is currently rendered, a background refetch triggers automatically — no manual coordination needed. This is the equivalent of cache-aside with automatic TTL-on-mutation, but driven from the client.
Request Deduplication
When multiple components mount simultaneously and each calls useQuery with the same key, only a single network request fires. TanStack Query, SWR, and Apollo all implement this: concurrent identical requests are merged into a single in-flight execution, and all callers receive the same result when it resolves.
For a backend engineer, this is analogous to request coalescing in a proxy layer. The browser becomes the boundary where duplicate work is eliminated before it ever hits your API.
The Rendering Strategy Spectrum
Frontend rendering is not a single thing. There is a spectrum of strategies, each with distinct latency characteristics:
Client-Side Rendering (CSR): The server sends an empty HTML shell; the browser downloads JavaScript, executes it, then fetches data. Every nested component with its own data dependency creates a sequential waterfall. Four sequential 300ms requests create roughly 1.2 seconds of wait time on a fast network.
Server-Side Rendering (SSR): The server fetches data and renders HTML before sending anything. Three concurrent 300ms data sources complete in ~300ms total on the server — compared to the same ~900ms sequential waterfall a CSR client would produce. The tradeoff: Time to First Byte increases because the server must complete its work before sending anything.
Streaming SSR: HTML is sent progressively as chunks, rather than waiting for all rendering to complete. This reduces perceived load time by up to 40% — users see content sooner even though the page is still arriving.
Incremental Static Regeneration (ISR): Pages are statically generated and served from CDN, but regenerate in the background after a configured revalidation period expires. Hybrid implementations combine time-based and on-demand (webhook-triggered) invalidation. This is the closest the frontend world comes to a read-through cache with configurable TTL.
Static Site Generation (SSG): Pages pre-render entirely at build time. Maximum performance from CDN; unavoidable data staleness. ISR emerged as the practical solution when SSG's staleness was unacceptable but SSR's per-request cost was too high.
Optimistic Updates
Optimistic UI updates immediately reflect a user's action in the interface before the server confirms it. The perception gain is 2–3x faster responsiveness: the user sees their change applied instantly rather than waiting for a round trip.
The mechanism requires a rollback contract: the pre-mutation state must be snapshotted, and if the mutation fails, the snapshot is restored. This is conceptually analogous to optimistic locking in a database — assume success, detect conflict, revert.
Optimistic updates provide the most value for operations with high server success rates and reversible actions: likes, favorites, bookmarks, status toggles. They are inappropriate when failure rates are meaningful or when the server response contains data the client cannot predict.
Fetch Waterfall vs. Parallel Fetching
Consider a user profile page that needs to display the user's profile, their posts, and their follower count. A naive CSR implementation might look like:
// Component A mounts → fetches user
// Component B (child) waits for user, then fetches posts
// Component C (child) waits for user, then fetches followers
//
// Timeline: [user: 300ms] → [posts: 300ms, followers: 300ms] = ~600ms minimum
This is a sequential waterfall where each request depends on the previous one completing. The page renders nothing meaningful until all three finish in sequence.
With parallel client-side fetching:
// All three fired simultaneously on mount
const { data: user } = useQuery({ queryKey: ['user', id], queryFn: fetchUser })
const { data: posts } = useQuery({ queryKey: ['posts', id], queryFn: fetchPosts })
const { data: followers } = useQuery({ queryKey: ['followers', id], queryFn: fetchFollowers })
// Timeline: max(300, 300, 300) = ~300ms
This is better — but still requires JavaScript to load and execute before any fetch fires. With SSR, all three fetches happen on the server during the render, completing in parallel before the first byte arrives at the browser.
Optimistic Update with Rollback in TanStack Query
const mutation = useMutation({
mutationFn: (newTitle) => updateTodo(todoId, newTitle),
// 1. Before the mutation fires: snapshot and apply optimistic update
onMutate: async (newTitle) => {
// Cancel any in-flight queries for this todo
// (prevents stale server response from overwriting our optimistic update)
await queryClient.cancelQueries({ queryKey: ['todos', todoId] })
// Snapshot the current value
const snapshot = queryClient.getQueryData(['todos', todoId])
// Optimistically update the cache
queryClient.setQueryData(['todos', todoId], (old) => ({
...old,
title: newTitle,
}))
// Return snapshot in context for rollback
return { snapshot }
},
// 2. If the mutation fails: restore from snapshot
onError: (err, newTitle, context) => {
queryClient.setQueryData(['todos', todoId], context.snapshot)
},
// 3. Always settle: invalidate to sync with server truth
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos', todoId] })
},
})
The three phases map to a clear contract:
onMutate: snapshot, cancel racing fetches, apply optimistic changeonError: restore snapshot (rollback)onSettled: invalidate to guarantee eventual consistency regardless of outcome
Note the query cancellation step: without cancelQueries, an in-flight background refetch could land after your optimistic update and silently overwrite it with the old server state.
From Redux-Everything to TanStack Query
A team maintains a React application with a Redux store. Over time, the store has grown to hold API responses alongside UI state: state.users.list, state.posts.currentPage, state.notifications.unread. Slice reducers handle FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE, loading flags, and pagination cursors.
What goes wrong:
-
Staleness is invisible. The users list fetched on app boot is still in the store 45 minutes later. Nothing tells the component it may be stale, and there is no mechanism to refetch in the background.
-
Invalidation is manual and error-prone. When the user creates a new post, someone has to remember to dispatch
INVALIDATE_POSTSand then re-trigger the fetch. Mutations and reads are coordinated by convention, not infrastructure. -
Every component owns its own loading state. Two components displaying user data each manage their own
isLoadinganderrorflags. When both mount, two identical requests fire — because Redux has no concept of request deduplication. -
Boilerplate compounds. Each new endpoint needs: action creators, reducers, selectors, loading flag logic, error flag logic. The codebase grows laterally with each new API resource.
The migration:
The team extracts server state into TanStack Query, keeping Redux only for genuinely client-owned state: the authenticated session, UI theme, and the shopping cart.
// Before: manual action + reducer + selector
dispatch(fetchUsers())
const users = useSelector(selectUsers)
const isLoading = useSelector(selectUsersLoading)
// After: one hook, all lifecycle handled
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: getUsers,
staleTime: 5 * 60 * 1000, // treat as fresh for 5 minutes
})
What changes:
- Background refetching now happens automatically when the window regains focus, the network reconnects, or the query is invalidated after a mutation.
- Two components mounting simultaneously share one request via deduplication.
- Post-mutation invalidation is a single call:
queryClient.invalidateQueries({ queryKey: ['posts'] }). - Loading and error states are colocated with the query — no separate reducer needed.
The lesson: Redux was built to manage state that your application owns. Server state is not owned by the application; it is borrowed from a remote source that can change at any time. The mismatch between Redux's model (deterministic, synchronous state machines) and server state's reality (asynchronous, shared, stale) is why the ecosystem converged on dedicated server-state libraries.
Quiz
1. You have a component that fetches a user's profile. Another component in the same render tree also calls useQuery({ queryKey: ['user', id] }). How many network requests fire on initial render?
- A) Two — each component manages its own fetch
- B) One — TanStack Query deduplicates concurrent requests with the same key
- C) Zero — TanStack Query batches until the next render cycle
- D) Depends on whether the data is already in the Redux store
2. A mutation succeeds and you want the user list to refresh. What is the correct TanStack Query call?
- A)
queryClient.resetQueries({ queryKey: ['users'] }) - B)
queryClient.removeQueries({ queryKey: ['users'] }) - C)
queryClient.invalidateQueries({ queryKey: ['users'] }) - D)
dispatch({ type: 'INVALIDATE_USERS' })
3. Your page shows a product listing and product details side by side. Each component fetches its own data. On a fast network, you observe that product details never load until the listing request completes, even though they're independent. This is an example of:
- A) Request deduplication
- B) A fetch waterfall
- C) Stale-while-revalidate
- D) ISR cache invalidation
4. A team reports that after a user updates their display name, the UI shows the old name for a few seconds before updating. This is the expected behavior of which pattern?
- A) Optimistic updates
- B) Stale-while-revalidate
- C) Cache normalization
- D) Background refetch after mutation invalidation
5. Your product page has three data sources: product info, reviews, and related items. On the server, fetching all three takes 300ms each (concurrent). On the client with CSR and a fetch waterfall, the same three requests are sequential. What is the approximate latency difference?
- A) No difference — the browser parallelizes automatically
- B) ~300ms server vs ~900ms client
- C) ~100ms server vs ~300ms client
- D) ~300ms server vs ~600ms client
Answers: 1-B, 2-C, 3-B, 4-D, 5-B
Key Takeaways
- Server state and client state are categorically different. Server state is remote, shared, and degrades over time. Client state is local and always current. Managing them with the same tool means applying one model where the other is needed.
- Stale-while-revalidate is the foundational pattern. Serve cached data immediately; revalidate in the background. Users see content without spinners; eventual consistency handles the rest.
- Query keys are the cache invalidation primitive. In TanStack Query, every piece of server state has a key. Mutations invalidate by key, triggering background refetch for active queries. This is cache-aside at the component layer.
- Fetch waterfalls are a client-side latency multiplier. Each sequential async dependency adds a full round-trip delay. Three sequential 300ms fetches cost 900ms; three parallel server-side fetches cost 300ms. Recognize and eliminate the sequential pattern.
- Optimistic updates require a rollback contract. Snapshot before mutating. Cancel racing fetches. Restore snapshot on failure. Invalidate on settle. These four steps are the complete pattern.
Further Exploration
TanStack Query
- Query Keys documentation — Canonical reference for the key structure and invalidation API
- Optimistic Updates guide — The complete onMutate / onError / onSettled pattern with examples
Rendering Strategies
- Next.js — Data Fetching overview — How SSR, SSG, ISR, and streaming SSR are implemented in a production framework
- React Server Components RFC — The original design document explaining why server components eliminate client-side waterfall patterns
Alternatives
- SWR documentation — Vercel's lightweight alternative implementing the same stale-while-revalidate semantics