Bỏ qua để vào nội dung chính
Server State vs Client State in React 2026: TanStack Query + Zustand

Server State vs Client State in React 2026: TanStack Query + Zustand

Bởi Hannah Cooper
02 thg 5, 20265 phút đọc

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 persist middleware to localStorage.
  • Client state, ephemeral: open modal, focused tab, hover states. Local useState or 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan