Quick take: - Zustand is store-first: define one object per domain and subscribe to slices. Jotai is atom-first: create one
atom()per piece of state and compose them. TanStack Query handles server state, API data, caching, background refetching, and doesn't overlap with either. Most production apps end up with Zustand + TanStack Query; add Jotai when per-item atomic granularity is genuinely needed, compared is covered here, today is covered here.
Zustand and Jotai come from the same team (pmndrs) and both handle client state. You're not choosing between good and bad, they're different models that suit different problems. TanStack Query enters the picture as a third tool that solves something neither of them was designed for: caching and synchronizing server data. Pick the wrong combination and you end up either reinventing a query cache in Zustand or handling auth state in TanStack Query, both of which create friction as apps grow.
What Is the Core Difference Between Zustand and Jotai?
The mental model is everything here.
Zustand is a store. You describe a domain as a single object with state and actions, then subscribe to slices of it in components. It's like a Redux slice with none of the ceremony.
Jotai is atomic. There's no store. You create standalone atom() units, essentially module-level useState, and compose derived atoms from them. Each component subscribes to exactly the atoms it needs, nothing more.
| Zustand | Jotai | TanStack Query | |
|---|---|---|---|
| Solves | Client state | Client state (atomic) | Server state |
| Model | Store (one object per domain) | Atoms (one per value) | Query cache (per queryKey) |
| Bundle size | ~1kB | ~4.7kB | ~13kB |
| Weekly downloads (2026) | 5.1M | 1.9M | 10.8M |
| Provider required | No | No | Yes (QueryClientProvider) |
| Re-render control | Selectors | Atomic subscriptions | Automatic (per query) |
| Async state | Middleware / manual | Built-in async atoms | Built-in (first class) |
| Caching | No | No | Yes, core feature |
| Background refetch | No | No | Yes (on window focus + interval) |
| DevTools | Redux DevTools | Jotai DevTools (separate pkg) | TanStack Query DevTools |
How Do Re-Renders Work in Each Library?
This question drives most of the Zustand vs Jotai debates online. The real answer: both give you minimal re-renders when used correctly.
With Zustand, you use a selector:
// Only re-renders when items.length changes, not on any other mutation
const count = useCartStore((s) => s.items.length);
Select the whole store and you'll re-render on every change. Use a selector and you won't. That's the one rule with Zustand.
Jotai handles this automatically at the atom level:
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);
// This component only re-renders when countAtom changes
const count = useAtomValue(countAtom);
No selector needed. The granularity is built into the atom model. I've found this genuinely useful when you have many small independent pieces of state, Jotai's approach is less error-prone because you can't forget a selector.
When Should You Choose Zustand?
Zustand is the better default for most projects. Use it when:
- You're managing domain-level state: authentication, cart, user preferences, notification queues
- Your team finds store-based thinking intuitive (very similar to Redux, minus the reducers)
- You need
persistmiddleware for localStorage sync out of the box - You want Redux DevTools to inspect state history without extra setup
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthStore {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}),
{ name: 'auth' }
)
);
I've used Zustand on four production apps now. The API is small enough to learn in an hour, and the TypeScript integration with create<StoreType>() is excellent. It doesn't get in your way.
When Should You Choose Jotai?
Jotai wins in specific situations where Zustand's store model creates awkward workarounds:
- Per-item state (a
completedflag per todo, anexpandedflag per accordion item) - Complex derived values that pull from multiple orthogonal atoms
- Async atoms with React Suspense integration
- Fine-grained subscriptions where you want automatic atom-level isolation
The atomFamily pattern is genuinely cleaner than anything you'd build on top of Zustand:
import { atom, useAtom } from 'jotai';
import { atomFamily } from 'jotai/utils';
// One separate atom per accordion item
const expandedAtomFamily = atomFamily((_id: string) => atom(false));
function AccordionItem({ id }: { id: string }) {
const [expanded, setExpanded] = useAtom(expandedAtomFamily(id));
return (
<div onClick={() => setExpanded((e) => !e)}>
{expanded ? 'Collapse' : 'Expand'}
</div>
);
}
Each component only re-renders when its specific atom changes. No selector, no shallow, no manual optimization.
When Should You Use TanStack Query?
TanStack Query (v5) manages server state: data fetched from an API, its loading and error states, background re-fetching, and cache invalidation. That's a different problem from what Zustand and Jotai solve, neither of them cache API responses or know when to refetch.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // treat data as fresh for 5 minutes
});
const mutation = useMutation({
mutationFn: (updates: Partial<User>) => updateUser(userId, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
if (isLoading) return <Spinner />;
return <div onClick={() => mutation.mutate({ name: 'New Name' })}>{user.name}</div>;
}
You get caching, deduplication (two components using the same queryKey share one fetch), background refetching on window focus, automatic retries, and cache invalidation on mutation. There's no equivalent in Zustand or Jotai. You'd have to build all of that by hand, and it wouldn't be as reliable. I migrated a mid-sized dashboard from manual fetch-plus-Zustand caching to TanStack Query and cut roughly 200 lines of retry and invalidation logic in a single afternoon.
TanStack Query doesn't replace client-state libraries. You still need Zustand or Jotai for UI flags, modal state, auth tokens, and cart contents. What it does replace is the useEffect + useState data-fetching pattern most teams were using before.
Which Library Does Your App Actually Need?
Here's the breakdown that covers most production React apps:
| State type | Best tool |
|---|---|
| API data, caching, background sync | TanStack Query |
| Auth, user session, cart | Zustand |
| UI flags, modal state, preferences | Zustand |
| Per-item state (per-row, per-accordion) | Jotai |
| Complex derived values from many atoms | Jotai |
| Async data with Suspense integration | Jotai (atomWithQuery) or TanStack Query |
The combination that handles most apps: Zustand + TanStack Query. TanStack Query takes over everything that touches an API. Zustand handles the rest of client state. Add Jotai only when you genuinely hit the limits of Zustand's selector model, typically when you need per-item granularity at scale.
Don't reach for all three at once. Start with Zustand and TanStack Query. If you find yourself writing Zustand selectors that feel overly complex for per-item UI state, that's the signal Jotai would help.
Can You Use All Three Together?
Yes. Zustand, Jotai, and TanStack Query don't conflict. In fact, the jotai-tanstack-query package provides atomWithQuery and atomWithMutation adapters that let you use TanStack Query's cache through Jotai's atom interface, useful in codebases already committed to the atomic model.
Don't mix all three prematurely. For most apps, even fairly complex ones, Zustand plus TanStack Query covers everything. It's my default starting point for every new React project. Add Jotai when you genuinely hit Zustand's granularity limits, not as a default third tool. The cost of having three state libraries in your bundle and your team's mental model is real.
Related
- Zustand, full API reference with Immer, persist, and TypeScript examples
- Jotai, atom API, atomFamily, async atoms, and Suspense integration
- TanStack Query, handles server state so neither library has to
- React 19 New Features, React 19 Actions change how you think about client-side mutation state
- TypeScript Generics Guide, type your Zustand stores and Jotai atoms with generic patterns