Managing Server State: Caching, Freshness, and Mutations

Understand why server state is fundamentally different from client state, and learn the vocabulary and patterns — caching, staleness, refetching, mutations, optimistic updates, query keys, pagination, and invalidation — used by server-state libraries like TanStack Query to manage it correctly.

Most React tutorials teach useState and useEffect as if all state were the same, but the data you fetch from a server behaves nothing like the data you type into a form. Server state is owned by a database somewhere else, can be changed by other users or other tabs without your component knowing, goes stale the moment you fetch it, and requires you to reason about caching, deduplication, and synchronization rather than just 'what value is this variable right now.' Treating server state as if it were ordinary client state is one of the most common sources of bugs in real React applications — stale UI, duplicate network requests, and race conditions between fetches.

Server state vs. client state

Client state is state your component fully owns and controls: whether a dropdown is open, which tab is selected, the current value of a text input before it's submitted. It's synchronous, it's yours, and useState or useReducer model it perfectly because nothing outside your app can change it behind your back.

Server state is different in almost every dimension. It's asynchronous to read (you have to fetch it over the network), it's persisted somewhere else (a database), it can be updated by other users, other tabs, or a background job you don't control, and it can technically go stale the instant you receive it. A useState holding 'the list of comments' is not really state you own — it's a local, possibly-out-of-date cache of data that lives on a server.

This distinction matters because the questions you need to answer are different. For client state you ask 'what is the current value?' For server state you have to ask a richer set of questions: is this data still fresh, or should it be refetched? Is a request for this data already in flight, so a second request would be wasted? If the user just changed this data, should the UI update immediately, or wait for the server to confirm?

This is exactly the gap that server-state libraries fill. Rather than modeling a fetch as 'call an API in useEffect, store the result in useState,' a library like TanStack Query (an ECOSYSTEM library, not part of React core) treats each piece of server data as an entry in a managed cache with its own lifecycle: fetching, fresh, stale, refetching, and error states are all handled for you.

Caching, freshness, and refetching

A cache stores the result of a fetch so that the next time the same data is needed, it can be served instantly from memory instead of triggering a new network round trip. But a cache is only useful if you also have a policy for when cached data is trustworthy versus when it needs to be refreshed — that policy is usually expressed as 'fresh' versus 'stale.'

Freshly fetched data is considered fresh for some configurable window (staleTime); during that window, if another component asks for the same data, the cache serves it immediately with no network request. Once the staleTime elapses, the data is marked stale — it's still shown to the user (so the UI doesn't flash to a loading state), but the library will trigger a background refetch the next time that data becomes relevant, for example when the window regains focus, the network reconnects, or the component remounts.

This 'stale-while-revalidate' pattern is the core mental model: show what you have immediately, then quietly check if it's still correct. It gives you both instant perceived performance and eventual consistency with the server, without the developer manually wiring up refetch logic for every possible trigger.

Query keys, deduplication, and pagination

A query key is how a server-state library identifies and caches a particular piece of data — typically an array like ['todos', { status: 'active' }] that uniquely describes what's being fetched and with what parameters. Two components asking for the same query key share the same cache entry: if both mount at nearly the same time, the library deduplicates the requests into a single network call instead of firing two, and both components re-render when the shared cache entry updates.

Because the key encodes the parameters, changing a filter or page number naturally produces a new key and therefore a new (separately cached) cache entry — this is what makes pagination and infinite queries work cleanly. A paginated query key like ['todos', { page: 2 }] caches page 2 independently of page 1, so navigating back to a previously visited page can be served instantly from cache. An infinite query extends this pattern by accumulating pages into a single growing list, fetching the 'next page' by re-using the last page's cursor or offset, which is exactly how infinite-scroll feeds are implemented.

Query keys are also the unit of cache invalidation: when you tell the library 'invalidate the ["todos"] key,' every cached entry whose key starts with ['todos'] — including ['todos', {status: 'active'}] and ['todos', {page: 2}] — is marked stale and refetched wherever it's currently being used on screen.

Mutations, optimistic updates, and consistency

A mutation is a request that changes server data — creating, updating, or deleting a resource — as opposed to a query, which only reads data. Mutations are usually triggered by a user action (submitting a form, clicking delete) rather than automatically on render, and after a mutation succeeds, the related query cache needs to be told that it's now stale so the UI reflects the change.

The simplest strategy is invalidate-on-success: after a mutation resolves, invalidate the query keys it affects (e.g. after creating a todo, invalidate ['todos']), which triggers a refetch and updates the UI once fresh data arrives. This is correct but can feel slow, since the user has to wait for the round trip before seeing their change reflected.

An optimistic update improves perceived speed by updating the local cache immediately, before the server has confirmed the mutation — you assume it will succeed and show the new state right away. If the mutation fails, you roll the cache back to its previous snapshot and typically show an error. This requires more careful bookkeeping (snapshotting the previous cache value so you can roll back), but it's what makes actions like 'liking a post' or 'checking off a todo' feel instantaneous.

The underlying goal in all of this is server-state consistency: the client's cached view should never drift permanently from what the server actually holds. Optimistic updates are a temporary, self-correcting departure from that consistency in exchange for responsiveness — the rollback-on-failure path is what keeps the guarantee intact.

A basic TanStack Query query and mutation (ECOSYSTEM)

The queryKey array is both the cache identity and the dependency list — changing the status filter would produce a new key and a new cached entry. invalidateQueries matches any cached key that starts with ['todos'], so filtered and paginated variants are all marked stale together.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function TodoList() {
  const queryClient = useQueryClient();

  const { data: todos, isLoading, isError } = useQuery({
    queryKey: ['todos', { status: 'active' }],
    queryFn: () => fetch('/api/todos?status=active').then((r) => r.json()),
    staleTime: 30_000, // fresh for 30s before a background refetch is allowed
  });

  const addTodo = useMutation({
    mutationFn: (title: string) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ title }),
      }),
    onSuccess: () => {
      // mark the 'todos' cache stale so it refetches everywhere it's used
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  if (isLoading) return <p>Loading…</p>;
  if (isError) return <p>Something went wrong.</p>;

  return (
    <ul>
      {todos.map((t: { id: string; title: string }) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  );
}

Code Examples

onMutate updates the UI immediately and returns a snapshot; onError restores that snapshot if the server rejects the change; onSettled always re-syncs with the server afterward so any drift is corrected. Expected result: The checkbox toggles instantly on click; if the network request fails, it visibly flips back to its prior state a moment later.

queryClient.setQueryData(['todos'], (old) => old.map(...)) updates the cache instantly, before the server has responded.

Each fetchNextPage call requests the next batch using the cursor returned by the previous page, and TanStack Query accumulates all fetched pages under a single cache entry keyed by ['posts']. Expected result: Clicking 'Load more' appends the next batch of posts to the list without refetching or duplicating the posts already shown.

const toggleTodo = useMutation({
  mutationFn: (id: string) =>
    fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' }),

  onMutate: async (id: string) => {
    // cancel any in-flight refetch so it doesn't overwrite our optimistic edit
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // snapshot the current cache so we can roll back on failure
    const previous = queryClient.getQueryData(['todos']);

    // optimistically flip the 'done' flag in the cache immediately
    queryClient.setQueryData(['todos'], (old: Todo[]) =>
      old.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );

    return { previous };
  },

  onError: (_err, _id, context) => {
    // roll back to the pre-mutation snapshot if the request failed
    if (context?.previous) {
      queryClient.setQueryData(['todos'], context.previous);
    }
  },

  onSettled: () => {
    // reconcile with the server regardless of outcome
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});
import { useInfiniteQuery } from '@tanstack/react-query';

function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['posts'],
      queryFn: ({ pageParam }) =>
        fetch(`/api/posts?cursor=${pageParam ?? ''}`).then((r) => r.json()),
      initialPageParam: null as string | null,
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    });

  const posts = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <div>
      {posts.map((p: { id: string; title: string }) => (
        <article key={p.id}>{p.title}</article>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading…' : 'Load more'}
        </button>
      )}
    </div>
  );
}

Query cache lifecycle

Stale data is never hidden from the user — it's shown immediately while a background refetch quietly checks whether it's still correct.

component mounts
         |
  queryFn runs -> data cached under queryKey
         |
     [FRESH]  (served instantly, no refetch)
         |  staleTime elapses
     [STALE]  (still shown, but eligible for refetch)
         |  refetch trigger (refocus, remount, invalidate)
  queryFn runs again -> cache updated -> [FRESH]

Important Rules

Do not model server data with plain useState/useEffect if the app has more than a couple of fetches — you will end up re-implementing caching, deduplication, and race-condition handling by hand, badly.

Design query keys to fully describe the request's parameters, since the key is what the cache uses to decide whether two components are asking for 'the same' data.

Invalidate the narrowest key that covers everything affected by a mutation — invalidating too broadly causes unnecessary refetches, invalidating too narrowly leaves stale data on screen.

Always pair an optimistic update with a rollback path (onError restoring a snapshot) and a final reconciliation (onSettled/invalidate) so temporary optimism never becomes permanent drift from the server.

Where this shows up

Nearly every data-driven product — a dashboard, an inbox, a social feed, an e-commerce catalog — relies on exactly this pattern: cache what's been fetched, show stale data instantly while quietly refreshing it, and update the UI optimistically for actions like likes or checkmarks so the app feels instant even on a slow connection.

useEffect(() => { fetch('/api/todos').then((r) => r.json()).then(setTodos); }, []); // re-fetched from scratch by every component that mounts this effect const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: () => fetch('/api/todos').then((r) => r.json()), });

The useEffect version has no shared cache: every component instance fetches independently, there's no deduplication of simultaneous requests, and there's no built-in mechanism to refetch when the data becomes stale — all of which useQuery provides automatically.

[multiple-choice] Why is server state harder to model with plain useState than client state like 'is this dropdown open'? A. Server state requires a bigger useState hook B. Server state can change outside your app's control and needs caching, freshness, and refetch logic, not just a stored value C. useState cannot store objects or arrays D. Server state is always synchronous

B. Server state can change outside your app's control and needs caching, freshness, and refetch logic, not just a stored value — Server state is owned elsewhere, can change without your component knowing, and requires answering questions about staleness, deduplication, and synchronization — concerns useState alone doesn't address.

[multiple-choice] What does invalidating a query key like ['todos'] do? A. Deletes the todos permanently from the server B. Marks every cached entry whose key starts with ['todos'] as stale so it refetches wherever it's currently displayed C. Clears all caches in the entire application D. Cancels any pending mutation

B. Marks every cached entry whose key starts with ['todos'] as stale so it refetches wherever it's currently displayed — Invalidation targets the cache entries matching (or prefixed by) that key, marking them stale so the library refetches them the next time they're rendered, keeping the UI eventually consistent with the server.

[multiple-choice] In an optimistic update, what is the purpose of snapshotting the previous cache value in onMutate? A. To speed up the network request B. It's required syntax with no functional purpose C. So the UI can be rolled back to that snapshot if the mutation later fails D. To permanently store a backup of all server data

C. So the UI can be rolled back to that snapshot if the mutation later fails — The snapshot captured before the optimistic edit is what onError uses to restore the cache to its pre-mutation state, ensuring a failed mutation doesn't leave the UI showing incorrect data.

Write a useQuery call for fetching a single user profile by id (queryKey should include the id), and a useMutation for updating that user's display name that invalidates the profile query on success.

const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(/api/users/${userId}).then((r) => r.json()), }); const updateName = useMutation({ mutationFn: (name: string) => fetch(/api/users/${userId}, { method: 'PATCH', body: JSON.stringify({ name }), }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['user', userId] }); }, });

The query key for a single resource typically looks like ['user', userId] so different users' profiles are cached separately.