blense.
HomeFrontendCodeAINewsGuidesCheatsheetsAbout

Technology decoded, always.

A curated read on AI, product, and the culture behind the code. No noise, no hype.

blense.

Technology with focus. Stories about what innovation really changes.

Sections

FrontendArtificial IntelligenceCode & DevTech News

Blense

AboutRSSPrivacy policyTermsCookies
© 2026 Blense · blense.fun
Frontend

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.

Gby Genildo SouzaApr 1410 min read
Zustand and TanStack Query: The Dynamic Duo That Simplified My React State Management

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.

The elite dev's arsenal.
Stop Using useMemo Wrong: A Practical Guide to React Performance14 minA Guide to HTML Loading Strategies That Will Speed Up Your Load Times9 minTanStack Query: How to Eliminate Manual Server State Management in React6 minNext.js 16 PPR: Eliminate the static vs. dynamic tradeoff and achieve Edge TTFB with fresh data14 min

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 boilerplateexport const useFilterStore = create<FilterState>()((set) => ({  selectedCategory: '',  sortOrder: '',  setSelectedCategory: (selectedCategory) => set({ selectedCategory }),  setSortOrder: (sortOrder) => set({ sortOrder }),}))// Consuming in any componentfunction 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 changeconst state = useFilterStore()// ❌ Creates a new object every render → infinite re-rendersconst { category } = useFilterStore((s) => ({ category: s.selectedCategory }))// ✅ Atomic selector — only re-renders when selectedCategory changesconst category = useFilterStore((s) => s.selectedCategory)// ✅ Multiple values: use useShallow for shallow comparisonimport { 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.tsexport const createFilterSlice = (set) => ({  selectedCategory: '',  sortOrder: '' as SortOrder,  setSelectedCategory: (selectedCategory: string) => set({ selectedCategory }),  setSortOrder: (sortOrder: SortOrder) => set({ sortOrder }),})// store/slices/uiSlice.tsexport 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 slicesexport 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.tsexport 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 detailqueryClient.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 reasonconst 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 functionuseQuery({  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 yetif (isError) return <ErrorAlert>{error.message}</ErrorAlert>// TypeScript now knows data is definedreturn (  <>    {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 preferencesexport 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 Queryexport 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 hooksfunction 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

TEXT
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.

Keep exploring
Want more content like this?

Check out other articles in the same vein and keep the momentum.

See more articles
#JavaScript#TypeScript#React#Programming

The elite dev's arsenal.

Stop Using useMemo Wrong: A Practical Guide to React Performance
Frontend

Stop Using useMemo Wrong: A Practical Guide to React Performance

Master React useMemo with this guide. Learn when to memoize, how to fix dependency array bugs, and why over-optimization can hurt your performance.

Genildo Souza · Jul 8 · 14 min
CSS Animations: How to Build Performant, Professional Interfaces Without Messy Code
Frontend

CSS Animations: How to Build Performant, Professional Interfaces Without Messy Code

Bring your web interfaces to life. Learn when to use CSS transitions versus keyframes and how to optimize your animations for peak performance.

Genildo Souza · Jul 4 · 17 min
Angular Micro-frontends: How to Scale Large Applications and Eliminate Monolithic Build Times with Module Federation
Frontend

Angular Micro-frontends: How to Scale Large Applications and Eliminate Monolithic Build Times with Module Federation

Discover how to break down large Angular monoliths using Module Federation to enable independent deployments and seamless team scaling.

Genildo Souza · Jun 21 · 10 min
In this article
  • Two Types of State, Two Different Problems
  • Zustand: Client State Without the Ceremony
  • The Minimal API
  • Selectors: The Detail That Defines Performance
  • Persist: Preferences That Survive a Reload
  • Slices Pattern for Separate Domains
  • TanStack Query: Server State Done Right
  • Hierarchical Query Keys and Cascading Invalidation
  • Cache, Stale-While-Revalidate, and Automatic Sync
  • Loading States in v5: isPending, isFetching, and isLoading
  • Mutations with Optimistic Updates and Rollback
  • The Duo in Practice: Real Integration
  • The Golden Rule
  • Decision Criteria: When to Use What
  • Integrated Example: Filter in Zustand, Query in TanStack
  • Zustand vs Redux: The Direct Comparison
  • Recommended Project Structure
  • Conclusion