Zustand + React Query: Modern State Management in React

Zustand + React Query: Modern State Management in React
Use React Query for server state and Zustand for UI & client state.
State management in React applications has evolved. While Redux and Context API have dominated the past, modern developers are moving toward lighter, simpler, and more efficient tools. Zustand, a minimalistic state manager, and React Query, a powerful data-fetching library, make a perfect combo.
In this guide, you'll learn why this pair is gaining popularity, best practices for using Zustand, and how to seamlessly combine both for a scalable, maintainable architecture.
Why Zustand?
Zustand is a small but mighty state manager with no boilerplate, excellent performance, and TypeScript support.
Key benefits:
- ⚡ Lightweight & Fast — Just a few KBs.
- ✨ No Boilerplate — No reducers, actions, or providers needed.
- 🧠 Simple API — Manage state with a
useStore
hook. - 🎯 Optimized Re-renders — Selectors prevent unnecessary UI updates.
- 🔄 Async Support — Built-in async action handling.
Example Zustand store:
1import { create } from "zustand";
2import { Post } from "../types";
3
4interface PostStore {
5 selectedPost: Post | null;
6 actions: {
7 setSelectedPost: (post: Post) => void;
8 };
9}
10
11const usePostStore = create<PostStore>((set) => ({
12 selectedPost: null,
13 actions: {
14 setSelectedPost: (post) => set({ selectedPost: post }),
15 },
16}));
17
18export const useSelectedPost = () => usePostStore((state) => state.selectedPost);
19export const usePostActions = () => usePostStore((state) => state.actions);
Zustand Best Practices
1️⃣ Export Custom Hooks
Only expose selectors and actions via hooks — not the entire store.
1// ✅ Good: selector hook
2export const useSelectedPostId = () => usePostStore((state) => state.selectedPostId);
2️⃣ Prefer Atomic Selectors
Avoid pulling multiple values from the store to reduce re-renders.
1// ❌ Bad: causes re-renders on any state change
2const { selectedPostId, setSelectedPostId } = usePostStore();
3
4// ✅ Good: atomic selectors
5export const useSelectedPostId = () => usePostStore((state) => state.selectedPostId);
3️⃣ Separate Actions from State
Group actions under an actions
object. This ensures they remain referentially stable and don't cause rerenders.
1const usePostStore = create<PostStore>((set) => ({
2 selectedPostId: null,
3 actions: {
4 setSelectedPostId: (id) => set({ selectedPostId: id }),
5 },
6}));
4️⃣ Model Actions as Events, Not Setters
Encapsulate logic inside your store, not your components.
1// ✅ Better: logic inside store
2const usePostStore = create<PostStore>((set) => ({
3 selectedPostId: null,
4 actions: {
5 selectPost: (id) => set({ selectedPostId: id }),
6 },
7}));
5️⃣ Keep Stores Small
Create multiple small stores instead of one large global store for better maintainability.
Why Combine Zustand with React Query?
Zustand excels at managing UI and client state. React Query handles server state like API data, background updates, and caching. Together, they solve different problems — without stepping on each other.
Zustand ⚡
- Minimal API, no boilerplate
- Global or local state
- First-class TypeScript support
- Built-in middleware support (logging, persisting, etc.)
React Query 🌍
- Server state caching
- Auto background refetching
- Pagination and infinite queries
- Normalized caching
- Optimistic UI updates
✅ Benefits of Combining
- Clear separation of concerns
- Smaller stores = better performance
- Avoids prop drilling and over-fetching
Integration Example: Zustand + React Query
1import { useQuery } from "@tanstack/react-query";
2import { fetchPosts } from "../api/posts";
3import { usePostActions } from "../store/usePostStore";
4
5const PostsList = () => {
6 const { data: posts, isLoading, error } = useQuery({
7 queryKey: ["posts"],
8 queryFn: fetchPosts,
9 });
10
11 const { setSelectedPost } = usePostActions();
12
13 if (isLoading) return <p>Loading...</p>;
14 if (error) return <p>Error loading posts</p>;
15
16 return (
17 <div>
18 <h2>Posts</h2>
19 {posts?.map((post) => (
20 <div
21 key={post.id}
22 onClick={() => setSelectedPost(post)}
23 style={{ cursor: "pointer", padding: "10px", border: "1px solid #ccc" }}
24 >
25 <h3>{post.title}</h3>
26 <p>{post.content}</p>
27 </div>
28 ))}
29 </div>
30 );
31};
💡 Why not use React Query for selected post? Because that’s UI state, not server state. Keep them separate.
Performance Tip: Normalize Data
Storing posts in an array means selecting a post by ID requires an O(n) search. At scale, that’s expensive.
Instead, normalize your data into an object:
1{
2 1: { id: 1, title: "..." },
3 2: { id: 2, title: "..." }
4}
Now lookups are O(1).
Real-World Use Cases
- 🧭 Selected item state (e.g. sidebar or form view)
- 🎨 UI toggles (e.g. theme, modal open state)
- ✍️ Draft post editing (local before submit)
- 🔒 Auth session metadata (e.g. current user)
Developers Are Making the Switch
Zustand and React Query are now the default stack for many teams building fast, scalable apps.
Trends:
- 🔥 Less boilerplate
- 🚀 Faster performance
- 🤝 Clear separation between server and UI logic
Final Thoughts
Zustand and React Query solve different, complementary problems — and they do it well. Zustand handles UI logic, React Query handles async data. If you're still using Redux or stuck with Context spaghetti, it's time to modernize.
More Resources
Stay tuned for our next guide on data normalization with Redux Toolkit and Zustand!