RoadmapDay 28 / 80
React (Web)Month 2 · Week 6

Day 28: React Query / RTK Query Internals (Caching, Stale Time, Invalidation)

Understand server-state caching as a distinct problem from client state, and reason correctly about staleTime, cacheTime, and invalidation.

Mark this day complete

Study

Concepts

Server state is not client state

Data fetched from a server is owned by the server, can go stale the instant you fetch it, and may be needed by multiple components simultaneously — none of which is true of local UI state like "is this dropdown open". React Query (TanStack Query) and RTK Query treat data-fetching as caching + synchronization: each query is keyed (`["todos", filters]`), and any component asking for the same key shares the SAME cached entry and in-flight request — calling `useQuery` five times for the same key triggers exactly one network request, not five.

`staleTime` controls how long cached data is considered fresh enough to serve without refetching, even if a component using that query mounts again. `cacheTime` (called `gcTime` in newer TanStack Query versions) controls how long UNUSED data stays in memory before being garbage-collected after the last component unsubscribes — data can be stale but still cached, and those are two independent axes.

Invalidation drives refetching after a mutation

After a mutation (POST/PUT/DELETE) succeeds, you explicitly mark related query keys as invalid (`queryClient.invalidateQueries(["todos"])`), which triggers a background refetch for any currently-mounted query using that key (or a prefix of it) — this is how the UI stays in sync with server truth after a write, without manually threading updated data back through props.

Both libraries also support optimistic updates: apply the expected result to the cache immediately (before the server responds) for an instant-feeling UI, then roll back to the previous cached value if the mutation ultimately fails — trading a small risk of a visible rollback for a much snappier perceived experience.

See It

Visualizations

Visualization

Life of a cached query

t=0

useQuery(["todos"]) mounts

no cache entry — fetch triggered

t=0.4s

Data cached, marked fresh

served instantly to this and any other subscriber

t < staleTime

Component remounts

served from cache — NO refetch

t > staleTime

Component remounts

stale-while-revalidate: shows cache instantly, refetches in background

mutation succeeds

invalidateQueries(["todos"])

forces a refetch for all mounted subscribers of this key

Build It

Code Examples

Query with explicit staleTime, and a mutation that invalidates it

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

function TodoList() {
  const { data: todos, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then((r) => r.json()),
    staleTime: 30_000, // fresh for 30s — remounts within that window skip refetching
  });

  const queryClient = useQueryClient();
  const addTodo = useMutation({
    mutationFn: (newTodo) =>
      fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }),
    onSuccess: () => {
      // Server truth changed — mark ["todos"] stale so subscribers refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  if (isLoading) return <Spinner />;
  return (
    <>
      {todos.map((t) => <TodoRow key={t.id} todo={t} />)}
      <button onClick={() => addTodo.mutate({ text: 'New task' })}>Add</button>
    </>
  );
}

Optimistic update with rollback on failure

jsx
const queryClient = useQueryClient();

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

  onMutate: async (id) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData(['todos']);

    queryClient.setQueryData(['todos'], (old) =>
      old.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    ); // instant UI update, before the server responds

    return { previous }; // passed to onError as 'context'
  },

  onError: (err, id, context) => {
    queryClient.setQueryData(['todos'], context.previous); // roll back
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] }); // resync with server truth either way
  },
});

Remember

Key Takeaways

  • Server-state caching is a different problem from client state: keyed, shared, and can go stale independently of your component tree.
  • Multiple components requesting the same query key share one cache entry and one in-flight request — no manual deduplication needed.
  • staleTime = how long data is trusted without refetching; cacheTime/gcTime = how long unused data stays in memory at all.
  • invalidateQueries after a mutation is how the cache resyncs with server truth — model every write as "this key(s) might now be wrong".
  • Optimistic updates apply the expected result immediately and roll back on failure — always pair onMutate with onError for safety.

Do It

Practice

  1. 1Build a todo list with React Query, set staleTime to 10s, and observe (via Network tab) when remounting the component does vs does not refetch.
  2. 2Add a mutation that invalidates ["todos"] on success and confirm the list refreshes automatically after adding an item.
  3. 3Implement an optimistic toggle-complete mutation with rollback, then force the mutationFn to reject and confirm the UI reverts correctly.