Server state and TanStack Query
Why server data isn't useState, query keys, staleTime and invalidation.
Updated
What server state is
Server state = data that lives on the server (in the database, in an API), while you only have a copy in the browser: users, products, orders, comments. The opposite is client state — data that exists only in the UI: an open modal, the text in an input, the theme.
The difference isn't academic — the two have completely different problems:
| Client state | Server state | |
|---|---|---|
| Examples | the theme, an open modal, a wizard step | users, products, orders |
| Owner | you (the UI) | the server — you have a copy |
| Synchronous | yes | no — it comes over the network |
| Can go stale | no | at any time (another user changed it) |
| Problems | where to keep it | loading, errors, caching, deduplication, refetching, invalidation, pagination |
| Tools | useState, the URL, Zustand |
Server Components, TanStack Query, SWR |
The classic mistake: server data in useState + useEffect. You end up reimplementing — badly — caching, loading, retries, deduplication.
Option 1: Server Components (Next.js)
You fetch the data on the server and render the HTML directly. There's no loading state on the client, no cache to keep in sync in the browser.
export default async function Page() {
const posts = await getPosts() // cached with 'use cache' + cacheTag('posts')
return <PostList posts={posts} />
}A mutation → a Server Action → updateTag('posts') → the page re-renders with fresh data.
Option 2: TanStack Query (on the client)
A library that manages server state in the browser: caching, refetching, deduplication, retries, pagination, mutations.
const { data, isPending, error } = useQuery({
queryKey: ['posts', { page }], // the data's identity in the cache
queryFn: () => api(`/api/posts?page=${page}`),
staleTime: 60_000, // fresh for 1 min → no refetch
})
const queryClient = useQueryClient()
const createPost = useMutation({
mutationFn: (post: NewPost) => api('/api/posts', { method: 'POST', body: JSON.stringify(post) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
})The key concepts
| Concept | What it means |
|---|---|
| queryKey | an array that identifies the data; everything that affects the result goes into the key (page, filters) |
| staleTime | how long the data is "fresh"; the default 0 = always considered stale |
| gcTime | how long it stays in the cache after nobody uses it (5 min by default) |
| invalidateQueries | marks it stale → refetch for what's on screen; matching by prefix |
| deduplication | 3 components ask for ['user', 1] → a single request |
| automatic refetch | on window focus, on reconnect, on mount (if stale) |
Optimistic updates
You show the result before the server responds, so the UI feels instant; if it fails, you roll back.
| Where | How |
|---|---|
| React 19 / Server Actions | useOptimistic |
| TanStack Query | onMutate (update the cache) + onError (rollback) |
How to choose
| Situation | The choice |
|---|---|
| Next pages that display data (a blog, a catalog, a profile) | Server Components |
| mutations from forms in Next | Server Actions + updateTag |
| a very interactive UI with server data: dashboards, infinite scroll, polling, live filters | TanStack Query (you can prefetch on the server) |
| an SPA without its own server | TanStack Query |
| pure UI state | useState / the URL / Zustand |
Summary
- Server state = a local copy of data from the server; it can go stale at any time.
- Don't put it in
useState+useEffect. - Next: Server Components + Server Actions; on the client: TanStack Query with
queryKey,staleTime, invalidation.