
Server State vs Client State in React 2026: TanStack Query + Zustand
Stop dumping server data in Zustand. The 4-quadrant model, TanStack Query for server state, Zustand for UI state, with Next.js 16 code.
Most React state-management debates start with the wrong question. The first question is not "Zustand or Redux?" — it is "is this state I own, or state the server owns?" Get that right and the library choice collapses to a footnote.
For the broader picture, see our complete React state management guide. For library-level tradeoffs, read Zustand vs Redux 2026.
What server state actually is
Server state is data the server owns: users, products, orders, comments, search results. You do not control when it changes. Other clients write to it. It can go stale at any moment. It needs caching, deduplication, background refetching, retries, and invalidation.
Client state is data the browser owns: which tab is active, whether a modal is open, the theme, an unsubmitted draft, a wizard step, a selected row. It is yours alone. Nothing on the server cares about it.
Treating these as the same thing is the single biggest source of bad React architecture in 2026.
The 4-quadrant model
- Server state, persistent: products, posts, the user's profile. Lives in TanStack Query cache; URL controls filters.
- Server state, ephemeral: autocomplete results, "is this email taken?" checks. Same tools, shorter
staleTime. - Client state, persistent: theme, sidebar collapsed, recent searches. Zustand with
persistmiddleware tolocalStorage. - Client state, ephemeral: open modal, focused tab, hover states. Local
useStateor a tiny Zustand store.
TanStack Query for server state
TanStack Query (formerly React Query) is not a "data fetching library." It is a server-state cache that happens to fetch. In the App Router with Next.js 16, set it up once at the root.
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000, refetchOnWindowFocus: true, retry: 1 },
},
}))
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
// app/products/[id]/product-actions.tsx
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
type Product = { id: string; title: string; bookmarked: boolean }
export function ProductActions({ id }: { id: string }) {
const qc = useQueryClient()
const { data, isPending } = useQuery<Product>({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then((r) => r.json()),
})
const toggle = useMutation({
mutationFn: () => fetch(`/api/products/${id}/bookmark`, { method: 'POST' }),
onMutate: async () => {
await qc.cancelQueries({ queryKey: ['product', id] })
const prev = qc.getQueryData<Product>(['product', id])
qc.setQueryData<Product>(['product', id], (p) =>
p ? { ...p, bookmarked: !p.bookmarked } : p,
)
return { prev }
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(['product', id], ctx.prev),
onSettled: () => qc.invalidateQueries({ queryKey: ['product', id] }),
})
if (isPending) return <span>Loading…</span>
return (
<button onClick={() => toggle.mutate()}>
{data?.bookmarked ? 'Bookmarked' : 'Bookmark'}
</button>
)
}
Note what is not in a Zustand store: the product, the bookmark status, the loading flag. All of it lives in the query cache.
Zustand for client state
Now the things that are client state — sidebar, theme, command palette open. This is what Zustand was built for.
// stores/ui-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type UIState = {
sidebarCollapsed: boolean
commandOpen: boolean
toggleSidebar: () => void
setCommandOpen: (open: boolean) => void
}
export const useUI = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
commandOpen: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setCommandOpen: (open) => set({ commandOpen: open }),
}),
{ name: 'ui-state', partialize: (s) => ({ sidebarCollapsed: s.sidebarCollapsed }) },
),
)
The store is small, opinionated, and never touches data the server owns.
Anti-patterns
- Storing server data in Zustand. You will reinvent caching, deduplication, and invalidation badly. Use TanStack Query.
- Manually syncing query data into Zustand. If you need it derived, derive it in a selector or
useMemo. - Putting filter state in Zustand. Filters belong in URL search params so they survive refresh and can be shared.
- One mega-store with both kinds of state. The library does not stop you. The architecture should.
Combining both in Next.js 16 App Router
Server Components fetch the initial data. Hand it to a Client Component, hydrate it into the query cache via HydrationBoundary, then let TanStack Query handle subsequent updates. Zustand stays out of the data path entirely.
// app/products/[id]/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { ProductActions } from './product-actions'
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const qc = new QueryClient()
await qc.prefetchQuery({
queryKey: ['product', id],
queryFn: () => fetch(`${process.env.API_URL}/products/${id}`).then((r) => r.json()),
})
return (
<HydrationBoundary state={dehydrate(qc)}>
<ProductActions id={id} />
</HydrationBoundary>
)
}
FAQ
Is TanStack Query a state manager?
Yes — for server state. It manages cache, freshness, mutations, and invalidation. Calling it "just a fetcher" is selling it short.
Why not just use one Zustand store for everything?
Because server state needs deduplication, retry, background refetch, stale-while-revalidate, and request cancellation. Rebuilding that on top of Zustand is months of work that TanStack Query already solved.
Where does form state belong, server or client?
Client. Use React Hook Form or similar. Submit through a TanStack Query mutation; do not put draft form values in the cache.
Do I need TanStack Query with React Server Components?
RSC handles initial render, but anything that mutates and refetches on the client (bookmarks, votes, comments, search) still benefits from TanStack Query. Use both.
How do I share server-fetched data between many client components?
Prefetch on the server, hydrate into the query cache once, and let every Client Component read via the same queryKey. No prop drilling, no context, no Zustand.
Get weekly highlights
No spam, unsubscribe anytime.
Dub.co
Short links & analytics for developers — track clicks, create branded links, manage affiliate URLs with ease.
Ranked.ai
AI-powered SEO & PPC service — fully managed, white hat, and built for modern search engines. Starting at $99/month.



Comments (0)
Sign in to comment
No comments yet. Be the first to comment!