Zustand and React Query: The Scalable State Management Pattern for React Apps
Learn how Zustand and React Query separate client and server state in React apps for cleaner architecture, better performance, and easier scaling.
I have spent years building React applications. At first, my state management was a wild west. I stuffed everything—user preferences, API responses, form data—into a single Redux store. It worked until the app grew. Then came the stale data, the unnecessary re‑renders, and the painful debugging sessions where I could not tell if a piece of state came from the server or from a local interaction. Sound familiar?
That chaos led me to a different philosophy: use the right tool for the job. Two tools, in particular, changed my approach completely: Zustand for client‑side global state and React Query for server state. They are not competitors. They are partners. When you integrate them correctly, you get a frontend architecture that scales without burning your codebase down.
The two kingdoms of state
Every React application deals with two fundamentally different kinds of state. Server state lives on a remote database. It is asynchronous, shared across users, and subject to staleness. Client state lives in the browser. It is synchronous, private to the user, and often ephemeral. Mixing them is the root of most state management bloat.
I used to keep a list of products in a global store. Every time the user changed a filter, I dispatched an action to fetch new products and replaced the array. The store grew fat with loading flags, error objects, and cached responses that were never invalidated. Then I discovered React Query. It handles all that: caching, background refetching, optimistic updates, and stale‑while‑revalidate. I no longer have to write reducers for “FETCH_PRODUCTS_START” or “FETCH_PRODUCTS_SUCCESS”.
But React Query is not a replacement for local state. I still need a place for the current theme, the sidebar collapse state, or the selected filter values. That is where Zustand shines. It is tiny, has no boilerplate, and lets me create stores with a single function. The two libraries live comfortably in separate layers.
The integration that makes sense
The magic happens when you let them talk to each other. The typical pattern: use a Zustand store to hold client‑side filter preferences, then pass those values as query keys to React Query. When the user changes a filter, Zustand updates the store. React Query sees the new query key and automatically refetches the data. No manual dispatch, no sync logic.
Here is a concrete example. Suppose I have a dashboard that shows user activity based on a date range. I store the date range in Zustand.
// store/dateRangeStore.js
import { create } from 'zustand';
const useDateRangeStore = create((set) => ({
startDate: new Date('2025-01-01'),
endDate: new Date('2025-12-31'),
setDateRange: (start, end) => set({ startDate: start, endDate: end }),
}));
export default useDateRangeStore;
Now I use that store inside a custom hook that calls React Query.
// hooks/useActivityData.js
import { useQuery } from '@tanstack/react-query';
import useDateRangeStore from '../store/dateRangeStore';
export const useActivityData = () => {
const { startDate, endDate } = useDateRangeStore();
return useQuery({
queryKey: ['activity', { startDate, endDate }],
queryFn: () => fetchActivityApi(startDate, endDate),
// React Query will refetch whenever startDate or endDate changes
});
};
That is it. The component that renders the chart calls useActivityData(). When the user picks a new date range, the Zustand store updates, React Query sees a new key, and the data refreshes automatically. No event bus, no custom listeners, no middleware.
What if you need to mutate data based on a piece of client state? For example, you want to update a user’s profile and then invalidate a specific query. You can call React Query’s queryClient from inside a Zustand action.
// store/userStore.js
import { create } from 'zustand';
import { useQueryClient } from '@tanstack/react-query';
const useUserStore = create((set, get) => ({
editingUserId: null,
setEditingUserId: (id) => set({ editingUserId: id }),
saveUser: async (userData) => {
// perform mutation
await saveUserApi(userData);
// invalidate queries that depend on this user
const queryClient = get().queryClient; // need access to queryClient
queryClient.invalidateQueries({ queryKey: ['users'] });
},
}));
Wait—how do you get access to queryClient inside a Zustand store? The cleanest way is to pass it when you create the store or use a React context. I prefer to keep Zustand stores pure and instead handle mutations in custom hooks that combine both libraries.
// hooks/useSaveUser.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useUserStore from '../store/userStore';
export const useSaveUser = () => {
const queryClient = useQueryClient();
const editingUserId = useUserStore((s) => s.editingUserId);
return useMutation({
mutationFn: (data) => saveUserApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users', editingUserId] });
},
});
};
Do you see the pattern? Zustand owns the client state. React Query owns the server state. React hooks orchestrate the dance between them. Each piece has a single responsibility.
Why this beats a monolithic store
I have been asked many times, “Why not just use Redux Toolkit with RTK Query?” Because I do not want a single tool to do two very different jobs. RTK Query is excellent, but it ties your server state logic to the same ecosystem as your client state. That coupling makes it harder to swap out the data fetching layer or to test client logic in isolation.
Zustand is incredibly easy to test. A store is a plain object with functions. You can create a store in a test, change state, and assert the output without mocking HTTP calls. React Query has its own powerful testing utilities. Together they form a clean, decoupled system.
Another advantage: performance. Zustand by default uses shallow equality to prevent unnecessary re‑renders. React Query only updates components when the data they need changes. Combine them, and you get an app that stays snappy even with hundreds of components watching different pieces of state.
I once worked on a real‑time analytics dashboard. The user could toggle between three views. Each view had its own filters stored in Zustand. React Query fetched the analytics based on those filters. When data came back, only the components that used that query re‑rendered. The filter toggles themselves stayed in a separate piece of state. That separation meant toggling a filter did not re‑render the entire sidebar—only the chart that depended on the new data.
How to get started
If you are new to either library, start small. Replace one piece of global state that holds API data with React Query. You will immediately notice how much boilerplate disappears. Then, if you still have client‑only state like a modal flag or a theme choice, introduce Zustand for that.
A common mistake is to put everything in Zustand because it is easy. Resist that urge. Ask yourself: “Does this value come from an API? Will another user’s action change it? If yes, let React Query own it.” The line is not always blurry—server state usually has a loading and error phase. Client state does not.
Here is a final code snippet that ties everything together: a simple counter that uses Zustand, and a list of posts that uses React Query based on that counter.
// store/counterStore.js
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 1,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
export default useCounterStore;
// hooks/usePosts.js
import { useQuery } from '@tanstack/react-query';
import useCounterStore from '../store/counterStore';
const fetcher = (page) => fetch(`/api/posts?page=${page}`).then(r => r.json());
export const usePosts = () => {
const count = useCounterStore((s) => s.count);
return useQuery({
queryKey: ['posts', count],
queryFn: () => fetcher(count),
});
};
// In component
function PostsPage() {
const { data, isLoading } = usePosts();
const increment = useCounterStore((s) => s.increment);
if (isLoading) return <div>Loading...</div>;
return (
<div>
<button onClick={increment}>Next page</button>
{data.map(post => <Post key={post.id} post={post} />)}
</div>
);
}
Simple, clean, and scalable.
Conclusion
After years of wrestling with state management, I found peace in a simple rule: server state belongs to React Query, client state belongs to Zustand. They are not rivals. They are a power couple that makes your codebase predictable, testable, and fast.
Have you ever stored API responses in a global store and then had to manually invalidate them? That pain is avoidable. The integration I showed you today is not a theoretical dream—it works in production apps handling millions of requests per day.
If this article helped you see state management in a new light, like it to help others find it. Share it with a teammate who still uses Redux for everything. And comment below: what is your biggest struggle with frontend state right now? I read every response. Let’s keep building better architectures together.
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