Zustand + React Query: Modern State Management in React

npmixnpmix
8
Zustand + React Query

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:

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

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

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

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

ts
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

ts
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:

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

👉 See the full example on GitHub


More Resources

Stay tuned for our next guide on data normalization with Redux Toolkit and Zustand!

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.