Zustand and React Query: The Smarter Way to Split Client and Server State

Learn when to use Zustand vs React Query to separate client and server state, reduce boilerplate, and build cleaner React apps.

Zustand and React Query: The Smarter Way to Split Client and Server State

I have been building web applications for over a decade, and if there is one mistake I keep seeing teams make, it is trying to treat all state like it is the same thing. You have a user profile, a list of orders, a dark mode toggle, and a modal that is currently open. Developers throw everything into a single global store, often Redux, and before long the store file is two thousand lines long. The data fetching, the loading flags, the local UI flags, it all becomes a tangled mess that nobody wants to touch. That is why I want to talk about pairing Zustand with React Query. This combination has completely changed how I structure frontend applications, and I think it can change yours too.

The core idea is simple. React Query handles everything that comes from a server: fetching, caching, refreshing, pagination, mutations. Zustand handles everything that lives purely in the browser: theme, side panel visibility, form input that hasn’t been submitted yet, user preferences that are not persisted on the backend. When you draw that line, your code becomes cleaner, your network requests become smarter, and your developers stop fighting over who owns a piece of state.

Let me show you what I mean. I had a project recently where we were building a dashboard for a logistics company. Users could filter shipments by status, toggle between a list and a grid view, and see real-time updates on delivery progress. The filters and the view toggle were pure client-side state. The shipment data came from a REST API. I used Zustand for the filter state and the view preference, and React Query for the shipment fetching. The two libraries never touched each other. They didn’t need to.

Now, before you run off and copy-paste a solution, ask yourself: what exactly belongs to which side? If the data changes on the server and must be reflected on screen, it is server state. If the data affects only how the UI looks or behaves, and it goes away when you refresh the page, it is client state. Simple, right? Yet I have seen developers store the result of an API call inside Zustand because they wanted a “single source of truth.” That is a mistake. React Query is that source for server data. Zustand is that source for UI knobs and switches.

Let’s look at a concrete example. First, the Zustand store for a simple application theme and a sidebar open flag:

import { create } from 'zustand';

const useUIStore = create((set) => ({
  theme: 'light',
  sidebarOpen: true,
  toggleTheme: () =>
    set((state) => ({
      theme: state.theme === 'light' ? 'dark' : 'light',
    })),
  toggleSidebar: () =>
    set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));

That is all. No actions, no reducers, no nonsense. Just a function and a set of properties. Now, for the server data, a React Query hook that fetches a list of shipments:

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

const fetchShipments = async () => {
  const response = await fetch('/api/shipments');
  if (!response.ok) throw new Error('Network error');
  return response.json();
};

export const useShipments = () => {
  return useQuery({
    queryKey: ['shipments'],
    queryFn: fetchShipments,
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
};

Notice that the Zustand store and the React Query hook live in separate files, separate concerns. When I want to toggle the sidebar, I call useUIStore.getState().toggleSidebar(). When I want to refetch shipments because a new filter was applied, I call queryClient.invalidateQueries({ queryKey: ['shipments'] }). They do not share memory. They do not share logic. They just work.

Now, what if you need to combine them? For example, you have a filter inside Zustand that should trigger a new server request. You can do that cleanly by reading the Zustand state inside the React Query hook’s query function. Here is an example:

import { useQuery } from '@tanstack/react-query';
import { useShipmentFilterStore } from './stores/filterStore';

const fetchFilteredShipments = async ({ queryKey }) => {
  const [, filters] = queryKey;
  const response = await fetch('/api/shipments', {
    method: 'POST',
    body: JSON.stringify(filters),
    headers: { 'Content-Type': 'application/json' },
  });
  return response.json();
};

export const useFilteredShipments = () => {
  const filters = useShipmentFilterStore((state) => state.filters);
  return useQuery({
    queryKey: ['filteredShipments', filters],
    queryFn: fetchFilteredShipments,
    enabled: !!filters,
  });
};

Every time the filters inside Zustand change (because the user clicks a status checkbox), the query key changes, and React Query automatically refetches. No manual dispatch, no useEffect watching a store. It is declarative and automatic. Have you ever written a useEffect that watches a Redux selector and calls a thunk? That pattern is dead. This is the replacement.

I recall one incident where a junior developer argued that we should put the server response inside Zustand so that other components could read it without using a hook. I asked them: what happens when the server response becomes stale? Do you manually invalidate it? Do you set a timer? They had no answer. That is the problem with mixing responsibilities. React Query handles staleness, background refetching, retries, and caching with zero configuration. If you store that data inside Zustand, you have to implement all of that yourself. Do not do that.

Pairing these two libraries also makes testing easier. When I test a component that shows a list of shipments, I mock useQuery and provide fake data. When I test a component that toggles a sidebar, I mock useUIStore with a test store. The two never interfere. I have seen teams struggle for days because their Redux store had so many interdependencies that mocking became a nightmare. Zustand and React Query avoid that by design.

One more personal note: I used to be a Redux enthusiast. I wrote middleware, normalized states, and created sagas for every little API call. Then I tried Zustand for a weekend project and never looked back. When I added React Query, I finally understood what “separation of concerns” really means in frontend development. It is not just a theoretical principle. It is a practical decision that affects how fast you ship features and how easy it is to onboard new developers.

Now, I want you to think about your current project. Are you storing API responses in a global store? Are you managing loading flags manually? If yes, you are making your life harder than it needs to be. And if you are using something like Redux Toolkit Query or SWR, that is fine too, but the combination of Zustand and React Query gives you a lighter, more flexible alternative that scales well without the extra boilerplate.

Let me give you one more code snippet for a common pattern: optimistic updates on a mutation. Suppose you have a to-do list. When a user adds an item, you want the UI to update immediately before the server responds. React Query handles this elegantly with useMutation, and you can still use Zustand for the new item’s local state before it is sent.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useToDoInputStore } from './stores/todoInputStore';

export const useAddTodo = () => {
  const queryClient = useQueryClient();
  const resetInput = useToDoInputStore((state) => state.reset);

  return useMutation({
    mutationFn: (newTodo) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
        headers: { 'Content-Type': 'application/json' },
      }),
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData(['todos']);
      queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
      resetInput();
      return { previous };
    },
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
};

The entire mutation lifecycle is handled by React Query. The input state (the text of the new to-do before submission) lives in Zustand. After submission, Zustand resets the input. That is the cleanest way I have found to keep UI responsive and data consistent.

If you are still reading, ask yourself: does your current state management solution make you happy? Does it make your codebase smaller or bigger? The combination of Zustand and React Query is not a silver bullet, but for most modern React applications, it is a very sharp tool that solves two distinct problems without creating new ones.

I encourage you to try it on your next feature. Start with a simple UI store in Zustand. Replace your API calls with React Query hooks. See how much boilerplate disappears. And when it works, you will wonder why you ever did it any other way. If you found this helpful, please like, share, and comment below. I want to hear how this pairing works for your team, or if you have found an even better combination. Let’s keep the conversation going.


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