Zustand and TanStack Query: The Dynamic Duo That Simplified My React State ManagementFrontend

Frontend · 13 Apr 2026

Zustand and TanStack Query: The Dynamic Duo That Simplified My React State Management

How combining Zustand for client state and TanStack Query for server state eliminates Redux boilerplate and creates a cleaner, more scalable React architecture.

Genildo Souza13 Apr 2026Read: 10 min

TL;DR: Zustand handles client state (UI, filters, preferences). TanStack Query handles server state (API data, cache, synchronization). Each solves exactly one problem — and neither interferes with the other. Together, they replace Redux + manual fetch logic with far less code and far more robustness.


Two Types of State, Two Different Problems

The foundation of this architecture is a conceptual distinction that feels obvious once you get it, but causes a lot of unnecessary complexity when missed: server state and client state are fundamentally different problems.

Server state is any data that lives outside your application — in a database, behind an API. It can change without the user doing anything (another user edited the same record, the server updated a price). It requires async fetching, caching, revalidation, and error handling. Managing it with manual useState + useEffect is reinventing the wheel — a square one.

Client state is what lives entirely in the browser: which tab is active, which filter the user selected, whether a modal is open, the UI theme. It’s synchronous, controlled, and has no staleness. It doesn’t need cache — just reactivity.

The TanStack Query docs are explicit about this: the library doesn’t replace client-state managers like Zustand. It solves the server side. Zustand solves the client side. Using both together is the architecture — not a choice between them.

Zustand: Client State Without the Ceremony

Zustand (German for “state”) was built on a simple premise: global state management shouldn’t require Providers, reducers, action creators, or connect(). A store is a hook. That’s it.

The Minimal API

typescript
import { create } from 'zustand'

interface FilterState {
  selectedCategory: string
  sortOrder: 'asc' | 'desc' | ''
  setSelectedCategory: (category: string) => void
  setSortOrder: (order: 'asc' | 'desc' | '') => void
}

// The entire store in one hook — no Provider, no boilerplate
export const useFilterStore = create<FilterState>()((set) => ({
  selectedCategory: '',
  sortOrder: '',
  setSelectedCategory: (selectedCategory) => set({ selectedCategory }),
  setSortOrder: (sortOrder) => set({ sortOrder }),
}))

// Consuming in any component
function SortControl() {
  const sortOrder = useFilterStore((s) => s.sortOrder)
  const setSortOrder = useFilterStore((s) => s.setSortOrder)
  return <select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
}

Selectors: The Detail That Defines Performance

The critical point most tutorials skip: Zustand compares selector return values via strict equality (Object.is). This means three ways of consuming the store have radically different performance implications.

typescript
// ❌ Subscribes to the entire store — re-renders on any change
const state = useFilterStore()

// ❌ Creates a new object every render → infinite re-renders
const { category } = useFilterStore((s) => ({ category: s.selectedCategory }))

// ✅ Atomic selector — only re-renders when selectedCategory changes
const category = useFilterStore((s) => s.selectedCategory)

// ✅ Multiple values: use useShallow for shallow comparison
import { useShallow } from 'zustand/react/shallow'

const { category, sortOrder } = useFilterStore(
  useShallow((s) => ({
    category: s.selectedCategory,
    sortOrder: s.sortOrder,
  }))
)

Practical rule: always export custom hooks that encapsulate selectors, not the store directly. This prevents components from accidentally subscribing to the entire store.

Persist: Preferences That Survive a Reload

typescript
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

export const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      sidebarOpen: false,         // ← we don't want to persist this
      setTheme: (theme) => set({ theme }),
      toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
    }),
    {
      name: 'app-preferences',
      storage: createJSONStorage(() => localStorage),
      // partialize: only persist what makes sense across sessions
      partialize: (s) => ({ theme: s.theme }),
    }
  )
)
// ⚠️ In Next.js with SSR: guard access behind a hydration flag
// since localStorage doesn't exist on the server.

Slices Pattern for Separate Domains

As the app grows, split the store by domain into slices and combine them into a bound store. Middlewares always go on the combined store, never on individual slices.

typescript
// store/slices/filterSlice.ts
export const createFilterSlice = (set) => ({
  selectedCategory: '',
  sortOrder: '' as SortOrder,
  setSelectedCategory: (selectedCategory: string) => set({ selectedCategory }),
  setSortOrder: (sortOrder: SortOrder) => set({ sortOrder }),
})

// store/slices/uiSlice.ts
export const createUISlice = (set) => ({
  theme: 'light' as Theme,
  modalOpen: false,
  setTheme: (theme: Theme) => set({ theme }),
  openModal: () => set({ modalOpen: true }),
  closeModal: () => set({ modalOpen: false }),
})

// store/index.ts — middlewares here, never in the slices
export const useBoundStore = create<BoundStore>()(
  persist(
    devtools((...a) => ({
      ...createFilterSlice(...a),
      ...createUISlice(...a),
    })),
    { name: 'app-store', partialize: (s) => ({ theme: s.theme }) }
  )
)

TanStack Query: Server State Done Right

TanStack Query is an async state manager. Every query is identified by a queryKey and executed by a queryFn. Caching, revalidation, automatic retries, and loading/error states come for free.

Hierarchical Query Keys and Cascading Invalidation

The query key is the cache index. The community-recommended pattern is the query key factory: a typed hierarchical structure that enables cascading invalidation and eliminates magic strings scattered across the codebase.

typescript
// queries/productKeys.ts
export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: number) => [...productKeys.details(), id] as const,
}

// Hierarchical invalidation: invalidates everything starting with ['products']
queryClient.invalidateQueries({ queryKey: productKeys.all })

// Invalidates only lists (not individual details)
queryClient.invalidateQueries({ queryKey: productKeys.lists() })

// Invalidates only a specific product's detail
queryClient.invalidateQueries({ queryKey: productKeys.detail(42) })

Cache, Stale-While-Revalidate, and Automatic Sync

TanStack Query implements the stale-while-revalidate pattern: when a query is stale, the component immediately receives cached data (no loading flash) while a background request revalidates. The two central parameters are:

  • staleTime: time in ms before data is considered obsolete. Default: 0 (always stale). Set higher values for data that rarely changes.

  • gcTime (formerly cacheTime): how long inactive cache entries stay in memory before garbage collection. Default: 5 minutes.

typescript
// Window focus refetching: when the user returns to the tab, stale data is revalidated
// Active by default — only disable globally if you have a real reason
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 2,       // data is fresh for 2 minutes
      refetchOnWindowFocus: true,      // default — keep it active
      refetchOnReconnect: true,        // revalidates on internet reconnect
    },
  },
})

// Polling: periodic refetch, conditional via function
useQuery({
  queryKey: productKeys.detail(jobId),
  queryFn: () => fetchJobStatus(jobId),
  refetchInterval: (query) => {
    // Stop polling when the job is done
    if (query.state.data?.status === 'complete') return false
    return 2_000 // every 2s while pending
  },
})

Loading States in v5: isPending, isFetching, and isLoading

v5 introduced an important distinction that broke v4 code. Understanding the three states prevents subtle bugs:

typescript
const { isPending, isFetching, isError, data, error } = useQuery({
  queryKey: productKeys.list(filters),
  queryFn: () => fetchProducts(filters),
})

// isPending: query has no cached data yet (first load)
// isFetching: queryFn is executing — includes background refetches
// isLoading: shorthand for isPending && isFetching (active first load)

// Correct v5 pattern:
if (isPending) return <Spinner />          // no data yet
if (isError) return <ErrorAlert>{error.message}</ErrorAlert>

// TypeScript now knows data is defined
return (
  <>
    {isFetching && <SubtleRefetchIndicator />}  // subtle background feedback
    <ProductGrid products={data} />
  </>
)

Mutations with Optimistic Updates and Rollback

The complete optimistic update pattern requires four disciplined steps. Skipping any one of them creates race conditions or inconsistent state.

typescript
function useUpdateProduct() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (product: Partial<Product> & { id: number }) =>
      api.products.update(product),

    onMutate: async (updatedProduct) => {
      // 1) Cancel in-flight refetches to prevent race conditions
      await queryClient.cancelQueries({ queryKey: productKeys.lists() })

      // 2) Snapshot the previous state (for rollback)
      const previousProducts = queryClient.getQueryData(productKeys.list(filters))

      // 3) Optimistically update the cache
      queryClient.setQueryData(productKeys.list(filters), (old: Product[]) =>
        old.map((p) => (p.id === updatedProduct.id ? { ...p, ...updatedProduct } : p))
      )

      return { previousProducts }
    },

    onError: (_err, _vars, context) => {
      // 4a) Roll back on error
      queryClient.setQueryData(productKeys.list(filters), context?.previousProducts)
    },

    onSettled: () => {
      // 4b) Always revalidate with the server — success OR error
      // Use onSettled, not onSuccess, to guarantee resync even after rollback
      queryClient.invalidateQueries({ queryKey: productKeys.lists() })
    },
  })
}

The Duo in Practice: Real Integration

The Golden Rule

Never copy server data into Zustand.

If you’re calling setQueryData or setState(api_result) in Zustand, you’re reinventing TanStack Query’s cache — badly. Zustand stores preferences; TanStack Query stores API responses. These boundaries must not cross.

Decision Criteria: When to Use What

Use TanStack Query for:

  • Any data fetched via REST API or GraphQL

  • Lists, detail views, pagination, infinite scroll

  • Data that can go stale because the server can change it

  • Mutations (create, update, delete) with automatic invalidation

Use Zustand for:

  • Filters, sorting, and search params that feed into queries

  • Theme, language, layout preferences

  • Modal, sidebar, and global toast state

  • Shopping cart before checkout (before persisting to the server)

  • Navigation flags, multi-step wizard state

Quick heuristic: if another system can change the data without the user acting, it’s server state. If only the user changes it, it’s client state.

Integrated Example: Filter in Zustand, Query in TanStack

This is the canonical pattern. Zustand filters go into the queryKey — when the user changes a filter, TanStack Query automatically fires a new request or serves from cache if that combination was already fetched.

typescript
// 1) store/useFilterStore.ts — Zustand holds user preferences
export const useFilterStore = create<FilterState>()(
  persist(
    (set) => ({
      selectedCategory: '',
      sortOrder: '' as SortOrder,
      setSelectedCategory: (selectedCategory) => set({ selectedCategory }),
      setSortOrder: (sortOrder) => set({ sortOrder }),
    }),
    { name: 'product-filters' } // preferences survive page reloads
  )
)

// 2) hooks/useProducts.ts — bridge between Zustand and TanStack Query
export function useProducts() {
  // useShallow prevents re-renders when other store fields change
  const { selectedCategory, sortOrder } = useFilterStore(
    useShallow((s) => ({
      selectedCategory: s.selectedCategory,
      sortOrder: s.sortOrder,
    }))
  )

  return useQuery({
    // Filters are part of the key — cache per filter combination
    queryKey: productKeys.list({ category: selectedCategory, sort: sortOrder }),
    queryFn: () => fetchProducts({ category: selectedCategory, sort: sortOrder }),
    staleTime: 1000 * 60 * 2,
    // Keep previous data visible while new data loads
    placeholderData: (prev) => prev,
  })
}

// 3) ProductList.tsx — component consuming both libs via hooks
function ProductList() {
  const { data, isPending, isFetching, isError, error } = useProducts()
  const setCategory = useFilterStore((s) => s.setSelectedCategory)

  if (isPending) return <Spinner />
  if (isError) return <ErrorAlert>{error.message}</ErrorAlert>

  return (
    <>
      <CategorySelector onChange={setCategory} />
      {isFetching && <SubtleRefetchIndicator />}
      <ProductGrid products={data} />
    </>
  )
}

The component has no idea two libraries are involved. It just consumes hooks. That’s the right level of abstraction.

Zustand vs Redux: The Direct Comparison

In 2025–2026, Zustand overtook Redux Toolkit in weekly downloads for new projects. The migration makes sense once you adopt TanStack Query for server state — because what’s left of global state is too small to justify Redux’s overhead.

  • Bundle: Zustand ~1 kB gzip vs Redux Toolkit ~12–14 kB (without RTK Query)

  • Boilerplate: Zustand is direct hooks vs slices + actions + reducers + Provider in Redux

  • Learning curve: Zustand has one central concept (create) vs actions/reducers/selectors/middleware/thunks in Redux

  • DevTools: both integrate with Redux DevTools via the devtools middleware

  • When Redux still wins: large teams with strict Flux architecture mandates, legacy codebases, or when RTK Query is already in use and migration isn’t worth it

Recommended Project Structure

Code
src/
  store/
    index.ts                # useBoundStore with all slices
    slices/
      filterSlice.ts        # UI filters
      uiSlice.ts            # theme, modals, sidebar

  queries/
    productKeys.ts          # query key factory
    useProducts.ts          # useQuery + Zustand filters
    useProductMutations.ts  # useMutation with optimistic update

  components/
    ProductList.tsx         # consumes useProducts()
    FilterPanel.tsx         # consumes useFilterStore()

  lib/
    queryClient.ts          # QueryClient with defaultOptions

Conclusion

The Zustand + TanStack Query architecture solves React’s historical state management problem with conceptual clarity: each library does exactly one thing and does it well. You replace a monolithic Redux store trying to handle everything with two specialists that don’t interfere with each other.

The practical result:

  • No manual fetch logic (useEffect + useState + loading + error + abort)

  • Automatic cache with request deduplication

  • Intelligent background revalidation with zero extra code

  • Global UI state with a ~1 kB bundle and a 10-line API

  • First-class TypeScript in both libraries

The rule that doesn’t change: never copy server data into Zustand. Server state lives in TanStack Query. Client state lives in Zustand. Components only consume via hooks. That separation is the architecture.