Skip to content

Zustand vs Jotai with TanStack Query Compared Today

Zustand, Jotai, or TanStack Query for React state? Comparison of re-render behavior, TypeScript patterns, and when to combine all three.

· · 13 min read

Updated: August 8, 2026

Two code panels comparing state management patterns in a code editor

Quick Take

Zustand works best for shared global state with store-level organization; Jotai shines for fine-grained atom-level re-render control; TanStack Query handles server state so you don't need to store API responses in either. Most production React apps in 2026 use all three for different concerns.

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.

Zustand vs Jotai is a comparison between two client-state libraries from the same team (pmndrs) that solve the same broad problem, keeping UI state in sync across components, with genuinely different models, one store-based and the other atom-based, and this guide has them compared side by side against TanStack Query, the third tool most 2026 production apps end up reaching for as well. 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. According to the Zustand documentation on GitHub, the library ships at roughly one kilobyte gzipped, which is one reason it pulls in about 5.1 million weekly downloads in 2026, more than double Jotai's 1.9 million. That gap isn't really about quality, both libraries are actively maintained and well tested, it mostly reflects how many teams default to a single global store because that's the model they already know from Redux. Jotai's smaller install base tends to correlate with apps that have genuinely fine-grained state, dozens or hundreds of independent toggles, rather than three or four domain stores, so the download numbers say more about typical app shape than about which library wins on technical merit.

ZustandJotaiTanStack Query
SolvesClient stateClient state (atomic)Server state
ModelStore (one object per domain)Atoms (one per value)Query cache (per queryKey)
Bundle size~1kB~4.7kB~13kB
Weekly downloads (2026)5.1M1.9M10.8M
Provider requiredNoNoYes (QueryClientProvider)
Re-render controlSelectorsAtomic subscriptionsAutomatic (per query)
Async stateMiddleware / manualBuilt-in async atomsBuilt-in (first class)
CachingNoNoYes, core feature
Background refetchNoNoYes (on window focus + interval)
DevToolsRedux DevToolsJotai DevTools (separate pkg)TanStack Query DevTools

How Do Re-Renders Work in Each Library?

Re-render behaviour drives most of the Zustand vs Jotai debates online. The real answer: both give you minimal re-renders when used correctly, the mechanism is just different. Zustand relies on you writing a selector that picks the exact slice a component needs; skip the selector and you'll re-render on every store change, which is the single most common Zustand performance bug I've seen in code review. Jotai builds the granularity into the atom model itself, so there's no selector to forget in the first place. Per the Jotai documentation, each useAtomValue call subscribes only to that specific atom's updates, not to a shared store object, which is why Jotai apps with hundreds of independent atoms tend to stay fast without any manual tuning.

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. This is 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.

A brass balance scale with two empty pans against a black background
Photo by Wesley Tingey on Unsplash

When Should You Choose Zustand?

Zustand is the better default for most projects, and in my experience it's the one I reach for first on any new codebase unless I already know the app needs dozens of independent per-item toggles. 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 persist middleware 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' }
  )
);

Zustand holds up in production better than its tiny size suggests. 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. The whole library lands around 1.2 KB minified and gzipped, which is roughly a tenth of what Redux Toolkit plus react-redux costs you, and it works outside React components too, so a useAuthStore.getState() call in an Axios interceptor or a route guard reads the same state without any provider in scope. That last detail is easy to overlook and it removes a surprising amount of plumbing from a typical auth flow.

How Does Zustand Compare to Redux?

Zustand replaces most of what Redux does with a fraction of the boilerplate: no action types, no dispatch, no reducer switch statements, and no separate react-redux package to wire up. A Redux Toolkit slice with three actions runs 30-40 lines; the equivalent Zustand store above runs 12. Redux still wins on one thing Zustand doesn't try to solve: enforced, predictable state transitions in a large team where you want every mutation funneled through named actions for auditability. If your team already has Redux middleware, sagas, or a compliance requirement around action logging, migrating off it is rarely worth the churn. For a new codebase in 2026, Zustand is the better starting point for almost everyone else. The migration path is gentler than people assume, too, since a Redux slice and a Zustand store hold the same shape of data and you can run both side by side while you move features across one at a time.

When Should You Choose Jotai?

Jotai wins in specific situations where Zustand's store model creates awkward workarounds:

  • Per-item state (a completed flag per todo, an expanded flag 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. The difference shows up at scale rather than in a toy example. Render a table of 500 rows where each row has its own expanded flag, and the Zustand version either keeps 500 booleans in one store object (every toggle notifies every subscriber unless each row writes a precise selector) or splits into 500 stores, which nobody wants to maintain. Jotai's atomFamily gives you the same 500 independent subscriptions with one line of setup. Per the Jotai documentation, atoms created this way are memoized by their parameter, so calling the family with the same id twice returns the identical atom rather than a new one, which is what keeps the subscription stable across renders.

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. Teams migrating a mid-sized dashboard from manual fetch-plus-Zustand caching to TanStack Query routinely delete a couple hundred lines of retry and invalidation logic in the process.

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.

A vintage beam weighing scale with two brass pans on a wooden base
Photo by Piret Ilver on Unsplash

Which Library Does Your App Actually Need?

Most production React apps need 2 libraries, not 3, and the split falls along a clean line: anything that came from a server belongs to TanStack Query, anything the user did in the browser belongs to Zustand. The breakdown below covers the usual cases:

State typeBest tool
API data, caching, background syncTanStack Query
Auth, user session, cartZustand
UI flags, modal state, preferencesZustand
Per-item state (per-row, per-accordion)Jotai
Complex derived values from many atomsJotai
Async data with Suspense integrationJotai (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.

What Changed in the Latest Releases?

None of these three libraries stood still this year, and the changes matter if you're picking today. Zustand's latest patch is v5.0.14 (May 2026), but don't expect new APIs, v5 spent its whole cycle dropping deprecated v4 patterns instead of adding features. Support for React below 18 is gone, and use-sync-external-store got replaced by the native useSyncExternalStore. The upside is a noticeably smaller bundle, useful if you're shipping Zustand to a size-sensitive edge runtime.

Jotai shipped something bigger. Version 2.20 (July 2026) reworked the internal store building blocks for high-throughput scenarios, fixing performance regressions that showed up in apps with hundreds of active atoms. Two follow-up patches, 2.20.1 and 2.20.2, closed remaining edge cases. The everyday API is unchanged, but there's one deprecation worth flagging now: atomFamily is deprecated ahead of Jotai v3 in favor of the separate jotai-family package. If you're starting a new project with the accordion pattern shown above, install jotai-family instead of pulling atomFamily from jotai/utils, the old import still works today but will be removed.

TanStack Query has been quieter, mostly dependency bumps and framework-adapter work. The latest release (5.101.2, June 2026) added Solid 2.0 beta support across the Router, Start, and Query packages. If you're not on Solid, there's nothing here that changes how you'd use useQuery or useMutation day to day.

Net effect for anyone starting a project today: the Zustand + TanStack Query combination still holds up as the default. Just swap in jotai-family if you reach for Jotai's atomFamily pattern going forward, since that's the one breaking change worth planning around before v3 lands.

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. That's the default I'd pick for any 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. Bundle size is the smaller half of it, all 3 together still come in under 20 KB gzipped. The bigger cost is the review question that shows up on every pull request: which of these does this piece of state belong in? Two libraries with a clear server-versus-client split answer that automatically. Three don't.

  • 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

Frequently Asked Questions

Is Zustand or Jotai better for TypeScript?
Both are excellent. Zustand types the entire store via create<StoreType>(). Jotai types individual atoms with atom<T>(). Jotai's per-atom typing is slightly more granular, but Zustand's approach is easier for domain objects like auth or cart state.
Can I use Zustand and Jotai in the same project?
Yes, and it can make sense. Use Zustand for domain-level stores (auth, cart, notifications) and Jotai for fine-grained component-level atoms. They don't conflict, they solve slightly different problems.
Which is faster, Zustand or Jotai?
Both are fast. Jotai's atom subscriptions skip more components per state change by default. Zustand matches Jotai's performance when you use selectors correctly. In practice, neither will be your bottleneck.
Is TanStack Query a replacement for Zustand or Jotai?
No, they solve different problems. TanStack Query handles server state: API data, caching, background refetching, and cache invalidation. Zustand and Jotai handle client state: UI flags, user preferences, cart contents, local form state. Most production apps need both: TanStack Query for anything that comes from an API, Zustand or Jotai for everything else.
What is the best state management combination for React apps in 2026?
For most apps: Zustand plus TanStack Query. TanStack Query handles all server state (API responses, loading states, caching), Zustand handles client state (auth, cart, UI preferences). Add Jotai only if you need fine-grained per-item atomic subscriptions that Zustand's selector model makes awkward.