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.
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
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
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
- 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.
- 2Add a mutation that invalidates ["todos"] on success and confirm the list refreshes automatically after adding an item.
- 3Implement an optimistic toggle-complete mutation with rollback, then force the mutationFn to reject and confirm the UI reverts correctly.