Zustand and React Query: Cleanly Separate Client and Server State in React

Learn how Zustand and React Query separate client and server state in React for cleaner architecture, better caching, and fewer bugs.

Zustand and React Query: Cleanly Separate Client and Server State in React

I’ve seen too many React apps where server data and UI state get thrown into one giant store, like mixing water and oil and hoping they stay together. The result is a tangled mess: components re-render for no reason, cached data goes stale without warning, and debugging becomes a hunt for which reducer accidentally wiped out the user’s preferences. That frustration is why I started looking for a cleaner separation. After trying several approaches, I settled on pairing Zustand for client state with React Query for server state. It’s not revolutionary, but it works. Let me show you how.

Have you ever wondered why your app feels sluggish even though you’re only fetching a simple list? The problem is often not the network but the state architecture. Client state—things like modal open/close flags, sidebar width, or a selected dropdown value—should live separately from server state—user profiles, product catalogs, order histories. When you mix them, you lose the ability to cache and refetch server data efficiently.

I start by creating a Zustand store for purely client concerns. For example, a UI store that holds the ID of a currently selected item and whether a side panel is visible. Notice there’s no API call inside this store—it only manages local UI logic.

import { create } from 'zustand'

const useUIStore = create((set) => ({
  selectedItemId: null,
  sidePanelOpen: false,
  selectItem: (id) => set({ selectedItemId: id }),
  togglePanel: () => set((state) => ({ sidePanelOpen: !state.sidePanelOpen })),
}))

That’s it. No boilerplate, no providers. I can use useUIStore in any component and get reactive updates for dropdown selections, tabs, or modals.

Now, for server state, React Query takes over. Instead of storing API responses in Zustand, I let React Query manage caching, background refetches, and stale data invalidation. Here’s a typical example: fetching item details based on the selected ID from the UI store.

import { useQuery } from '@tanstack/react-query'

function ItemDetail() {
  const selectedItemId = useUIStore((state) => state.selectedItemId)

  const { data, isLoading, error } = useQuery({
    queryKey: ['item', selectedItemId],
    queryFn: () => fetch(`/api/items/${selectedItemId}`).then((res) => res.json()),
    enabled: !!selectedItemId,
  })

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>Error loading item</p>
  return <div>{data.name}</div>
}

The key here is enabled: !!selectedItemId. Without that, the query would run with a null ID. React Query only fires the fetch when a valid selection exists. This pattern keeps the data layer reactive to client state changes without polluting the UI store with loading booleans or cached responses.

I remember one project where we initially stored the fetched list inside Zustand and manually fetched again on button clicks. Every new feature required a new action, a new reducer, and eventually an unreadable mess. After switching to React Query, we cut the state store in half. The UI store only had two pieces of state: which item was selected and whether a panel was open. Everything else came from React Query.

Why does this matter? Because server state evolves. The user might add a comment while you’re looking at the list. React Query’s stale timing and invalidation handle that automatically. In contrast, client state is ephemeral. The sidebar width resets on refresh, and that’s fine.

A strong integration point is when a mutation (like updating an item) needs to reflect in the query cache. You can combine a React Query mutation with a call to the Zustand store to reset selection or close a panel.

import { useMutation, useQueryClient } from '@tanstack/react-query'

function useUpdateItem() {
  const queryClient = useQueryClient()
  const clearSelection = useUIStore((state) => state.selectItem)

  return useMutation({
    mutationFn: (updatedItem) =>
      fetch(`/api/items/${updatedItem.id}`, {
        method: 'PUT',
        body: JSON.stringify(updatedItem),
      }).then((res) => res.json()),
    onSuccess: (data) => {
      // Invalidate the specific item query
      queryClient.invalidateQueries({ queryKey: ['item', data.id] })
      // Also invalidate the list query
      queryClient.invalidateQueries({ queryKey: ['items'] })
      // After success, clear selection in client state
      clearSelection(null)
    },
  })
}

Notice how the UI store (clearSelection) is updated inside the mutation’s success callback. This is a clean way to synchronize client and server states after a write operation. No boilerplate, no context wrangling.

Sometimes people ask: why not use React Query’s built-in global state (like query client cache) for everything? Because client state doesn’t need cache timeouts. A modal’s open state should reset on page navigation, not persist for minutes. Zustand gives me fine-grained control over that ephemeral data, and React Query handles the asynchronous, server-derived stuff.

I once had a complex dashboard where a filter (client state) controlled a grid (server state). Each filter change triggered a new query. Before, we stored the filter in Redux and manually dispatched fetch actions. With Zustand + React Query, we simply read the filter value inside the query’s function key. React Query automatically re-fetched when the key changed. The UI state remained lightweight, and the server data stayed in sync.

This approach scales beautifully. You can add optimistic updates, paginated queries, infinite scroll, or prefetching without touching the client store. The separation forces you to think: “Is this piece of state owned by the user’s browser alone, or does it come from somewhere else?” That clarity reduces bugs and makes onboarding new developers easier.

Now, I’d like you to try a small exercise. Take any component in your app that uses internal state for both UI and fetched data. Move the UI state into a Zustand store, move the fetch logic into a React Query hook, and connect them via the hook. You’ll likely cut your lines of code by half and feel the mental overhead vanish.

If you found this approach helpful, please like this article, share it with a colleague who still uses one giant Redux store for everything, and let me know in the comments how you separate your client and server state. I read every response and learn from your experiences too.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Our Network

More from our team

Explore our publications across finance, culture, tech, and beyond.

// More Articles

Similar Posts