
Zustand vs Jotai vs Redux Toolkit in 2026 comes down to one question: how much of what you call "state" is actually server state pretending to be client state? If you're a large team building a dashboard with mostly remote data and you want time-travel debugging, Redux Toolkit (with RTK Query) is still the safest pick. If you want zero boilerplate and a 4KB hook-based store for typical app state, Zustand wins. If your UI has tightly-coupled derived values (form builders, design tools, spreadsheets), Jotai's atomic model is the right shape.
Here's the honest 3-way: where each one still wins, the same counter + todo example written three ways, and the harder question we'll get to at the end. Do you even need any of them now that React 19 ships useOptimistic, Server Components, and Server Actions, and TanStack Query has eaten most of the "global store" use case?
| Library | Bundle (gzip) | Best for | Avoid when |
|---|---|---|---|
| Redux Toolkit | ~13-14 KB | Large teams, time-travel, RTK Query for server state | Solo founders, small apps |
| Zustand | ~1-3 KB (core) | Most apps. Quick. Hook-native. | You need strict patterns enforced across 30 engineers |
| Jotai | ~3-4 KB | Form builders, derived state, fine-grained re-renders | Simple flat state with no relationships |
Most teams that pick Redux Toolkit don't actually need it. Most teams that pick Zustand were right. Most teams that pick Jotai picked it for a real reason and stayed.
The narrative that "Redux is dead" is half right and half lazy. The original Redux (action types, switch statements, mapStateToProps) is dead. Redux Toolkit, the official wrapper that ships createSlice, createAsyncThunk, and RTK Query, is very much alive and still the right choice in three specific situations.
Time-travel debugging at scale. Redux DevTools is still the best state debugging experience in React. Replay actions, scrub a timeline, export a bug repro for another engineer to load locally. Zustand connects to the same DevTools but loses action semantics; Jotai's React DevTools shows atoms without the timeline. For a 40-engineer team where bugs ship as state snapshots, time-travel is not optional.
RTK Query is the actual moat. RTK Query is a normalized data-fetching cache that ships in the same package and gives you generated hooks for queries and mutations. It's a credible alternative to TanStack Query, with one mental model for client and server state. The same shape works in your slice, your devtools timeline, and your tests.
Battle tested at scale. Redux has survived since 2015. Every edge case has a Stack Overflow answer, every staff engineer has seen it, and your hiring pool is enormous. For 30+ engineers that need strict patterns enforced (one slice per feature, traceable mutations, opinionated folders), the ceremony is a feature, not a bug.
Where it loses: bundle size (~13-14 KB gzipped including Redux core), setup ceremony for small apps, and a learning curve that doesn't pay back until your codebase is large.
Zustand is the closest thing the React ecosystem has to a default. ~3 KB. No provider. One hook. State and updaters in the same place. The mental model is "useState, but global." If you've used useState you can use Zustand in five minutes.
Zero boilerplate, hook-native. Define a store as a function, read it as a hook, update it by calling a method. No slice file, no action file, no reducer, no useDispatch. For a solo founder shipping fast, that gap is the whole game.
Selectors prevent re-renders. useStore(state => state.user.name) only re-renders when name changes. Same explicit selector model as useSelector, without the ceremony. shallow from middleware subscribes to multiple slices.
Middleware for the boring stuff. persist gives you localStorage hydration in two lines. devtools connects to Redux DevTools. immer gives you mutable-style updates. Small ecosystem, covers what most apps need.
Where it loses: no time-travel that's as rich as Redux DevTools, no RTK-Query-equivalent for server state (you pair it with TanStack Query), and the lack of enforced structure can bite a 30-engineer team that needs guardrails.
Jotai is the spiritual successor to Recoil (which Facebook quietly stopped maintaining). It models state as primitive atoms, and atoms can derive from other atoms. It feels like a spreadsheet. Change one cell, and only the cells that depend on it recompute.
Atomic primitives. No single store. Many small atoms, each one independent: a countAtom, a userAtom, a themeAtom. Components subscribe to specific atoms, and rerenders are fine-grained by default. The "I touched user so the whole tree re-renders" problem doesn't exist because you can't do that.
Derived atoms are the killer feature. A derived atom computes its value from other atoms. A cartTotalAtom deriving from cartItemsAtom and taxRateAtom re-evaluates when either changes. No selectors, no useMemo, no manual dependency tracking. This is why form builders, design canvases, and spreadsheet UIs reach for Jotai.
Suspense-friendly async atoms. A Jotai atom can return a Promise; Suspense handles the loading state. Composes cleanly with React 19's use() hook and Server Components. For graphs of data dependencies, this is much nicer than threading loading flags through Redux thunks.
Where it loses: there's a learning curve to think in atoms instead of stores, the devtools story is weaker than Redux's, and for flat state with no relationships you're paying conceptual overhead for nothing.
The fastest way to feel the difference is to read the same code three ways. Here's a counter and a todo list in each.
// store.js
import { configureStore, createSlice } from '@reduxjs/toolkit'
const counter = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
inc: (s) => { s.value += 1 },
dec: (s) => { s.value -= 1 },
},
})
const todos = createSlice({
name: 'todos',
initialState: [],
reducers: {
add: (s, a) => { s.push({ id: Date.now(), text: a.payload, done: false }) },
toggle: (s, a) => {
const t = s.find(x => x.id === a.payload)
if (t) t.done = !t.done
},
},
})
export const { inc, dec } = counter.actions
export const { add, toggle } = todos.actions
export const store = configureStore({
reducer: { counter: counter.reducer, todos: todos.reducer },
})
// App.jsx
import { Provider, useDispatch, useSelector } from 'react-redux'
import { store, inc, add, toggle } from './store'
function App() {
const count = useSelector(s => s.counter.value)
const todos = useSelector(s => s.todos)
const dispatch = useDispatch()
return (
<>
<button onClick={() => dispatch(inc())}>{count}</button>
<button onClick={() => dispatch(add('hello'))}>add</button>
{todos.map(t => (
<div key={t.id} onClick={() => dispatch(toggle(t.id))}>
{t.done ? 'done' : 'open'}: {t.text}
</div>
))}
</>
)
}
export default function Root() {
return <Provider store={store}><App /></Provider>
}
// store.js
import { create } from 'zustand'
export const useStore = create((set) => ({
count: 0,
todos: [],
inc: () => set((s) => ({ count: s.count + 1 })),
add: (text) => set((s) => ({
todos: [...s.todos, { id: Date.now(), text, done: false }],
})),
toggle: (id) => set((s) => ({
todos: s.todos.map(t => t.id === id ? { ...t, done: !t.done } : t),
})),
}))
// App.jsx
import { useStore } from './store'
export default function App() {
const { count, todos, inc, add, toggle } = useStore()
return (
<>
<button onClick={inc}>{count}</button>
<button onClick={() => add('hello')}>add</button>
{todos.map(t => (
<div key={t.id} onClick={() => toggle(t.id)}>
{t.done ? 'done' : 'open'}: {t.text}
</div>
))}
</>
)
}
No provider. No dispatch. The whole thing is one file.
// atoms.js
import { atom } from 'jotai'
export const countAtom = atom(0)
export const todosAtom = atom([])
export const addTodoAtom = atom(null, (get, set, text) => {
set(todosAtom, [...get(todosAtom), { id: Date.now(), text, done: false }])
})
export const toggleTodoAtom = atom(null, (get, set, id) => {
set(todosAtom, get(todosAtom).map(t => t.id === id ? { ...t, done: !t.done } : t))
})
export const openCountAtom = atom((get) => get(todosAtom).filter(t => !t.done).length)
// App.jsx
import { useAtom, useSetAtom, useAtomValue } from 'jotai'
import { countAtom, todosAtom, addTodoAtom, toggleTodoAtom, openCountAtom } from './atoms'
export default function App() {
const [count, setCount] = useAtom(countAtom)
const todos = useAtomValue(todosAtom)
const add = useSetAtom(addTodoAtom)
const toggle = useSetAtom(toggleTodoAtom)
const openCount = useAtomValue(openCountAtom) // derived for free
return (
<>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<div>open todos: {openCount}</div>
<button onClick={() => add('hello')}>add</button>
{todos.map(t => (
<div key={t.id} onClick={() => toggle(t.id)}>
{t.done ? 'done' : 'open'}: {t.text}
</div>
))}
</>
)
}
Notice openCountAtom is a derived atom: any component reading it recomputes only when todosAtom changes. In Redux you'd write a memoized selector; in Zustand you'd write a derived selector function. In Jotai it's the default shape.
| Factor | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|
| Bundle (gzip) | ~13-14 KB | ~1-3 KB | ~3-4 KB |
| Setup lines | ~15 (store, provider, slice) | ~3 (one create call) | ~1 per atom |
| Boilerplate | Medium (slices) | None | None, but more files for atoms |
| Provider needed | Yes | No | Optional (recommended) |
| Time-travel devtools | Best in class | Via middleware | Limited |
| Server-state story | RTK Query (built in) | Pair with TanStack Query | Pair with TanStack Query or jotai-tanstack-query |
| Derived state | Memoized selectors | Selector functions | Built-in atoms |
| Async pattern | createAsyncThunk, RTK Query | Plain async functions | Async atoms + Suspense |
| Mental model | Flux (actions, reducers, store) | Hook with state + setters | Composable atoms |
| Best fit | Large teams, dashboards, server-heavy apps | Most apps, fast iteration | Form builders, design tools, derived state |
Worth a quick honest pass since the field is wider than three.
Valtio. From the same author as Zustand and Jotai. It uses a mutable proxy: you mutate the object, components re-render. About 3 KB. It's the cleanest option if you actively dislike immutability boilerplate. The tradeoff: no time-travel, and the magic can confuse new contributors.
MobX. Still excellent for object-oriented codebases (think classes, observables, computed). It has lost mindshare to Zustand and Jotai for new React projects, but it's not dead. You'll see it most in legacy enterprise frontends and in Vue-flavored React shops.
Effector and Signals (Preact, SolidJS-style). Effector is a power tool with steep learning curve and fans who swear by it for complex reactive flows. Signals (via @preact/signals-react) are the fastest for high-frequency updates because they bypass React's reconciler for primitive reads. Both are niche picks. You don't reach for them by default.
Recoil. Effectively dead. Facebook stopped meaningful work, the maintainers moved on. If you have it in production, plan a Jotai migration; the APIs are similar enough that it's mostly mechanical. If you're considering writing a comparison post that frames the modern stack, the Recoil-to-Jotai story is an honest one to tell.
This is the harder question and most "vs" posts dodge it. With React 19 in stable, the calculus has shifted.
Most "state" is server state. If you've ever written a Redux slice that just stored API responses, you wrote a worse version of TanStack Query or RTK Query. Server state has different needs than client state: caching, background refetch, stale-while-revalidate, dedupe across components, optimistic updates. A general-purpose store handles none of this well.
React 19 ships useOptimistic. Optimistic updates were a classic reason to reach for Redux: dispatch the optimistic action, dispatch the rollback on error. With useOptimistic and Server Actions, that pattern is now a built-in hook. You can write the most common optimistic case (toggle a like, mark a todo done, edit a form) without any global store at all.
Server Components push state to the server. If your data lives in a database and renders on the server, you may not need to store it in client state at all. The whole "fetch in useEffect, store in Redux, re-render" loop becomes "fetch in a server component, render once." Your client state shrinks to genuinely interactive bits: open/closed dialog, current tab, draft form values.
The honest 2026 default. For a typical SaaS app started today: TanStack Query for server state, useState for component state, React Context for theme/auth, and reach for Zustand only when prop drilling gets painful. You may never need Redux Toolkit or Jotai unless your app has a specific shape (large team, derived-state-heavy UI, server-state monoculture). The same logic applies whether you're picking between Tailwind and vanilla CSS or Linear, Jira, and GitHub Projects: defaults beat preferences for most teams.
That doesn't mean these libraries are obsolete. It means they should solve real problems, not imaginary ones. Pick the smallest tool that actually fits.
Decision tree, short version:
useState. No store.Whichever you pick, the actual cost is not the library. It's the engineering hours you spend wiring it correctly and untangling the wrong abstraction six months later. That's where Cadence comes in. Cadence is an on-demand engineering marketplace: every engineer on the platform is AI-native by default (vetted on Cursor, Claude Code, and Copilot fluency in a voice interview before they unlock the platform), so the boilerplate cost of any of these libraries is roughly halved. A Zustand refactor that used to be a two-week project ships in a week. A Redux Toolkit migration that used to need a senior IC for a month gets done in two.
Pricing: junior $500/week, mid $1,000/week, senior $1,500/week, lead $2,000/week. Weekly billing, 48-hour free trial, replace any week. With a pool of 12,800 engineers across React, Next.js, and the broader frontend stack, you'll find the right shape for your migration in two minutes. The honest read: a comparison like this only matters if you have someone qualified to act on it. Booking is faster than hiring.
If you're picking between GitHub, GitLab, and Bitbucket or wrestling with Docker vs Podman at the same time, the same logic applies: pick the smallest tool that fits, and book the right engineer to ship it.
No. Redux Toolkit is still actively maintained and remains the right pick for large teams (30+ engineers), apps that lean heavily on RTK Query for server state, and teams that need time-travel debugging. The "Redux is dead" narrative confuses Redux Toolkit (the modern API) with vintage Redux (action types, switch statements). The vintage version is dead; the toolkit version is healthy.
Yes, and it's a common pattern. Use Zustand for global app state (current user, theme, feature flags) and Jotai for tightly-coupled UI state with derived values (a form builder, a design canvas). They don't conflict because they don't share runtime state. The mental tax is keeping the boundary clear in your head.
Less than you think. Most state in a typical app is server state, which TanStack Query, RTK Query, or Server Components handle better. Reserve client state libraries for genuinely client-only state: open dialogs, current filter selections, draft form values. If your app has very little of that, you can ship with useState and React Context and skip the store entirely.
For typical apps, the difference is invisible to users. Jotai's atomic model gives the most fine-grained re-renders by default. Zustand and Redux Toolkit match it with manual selectors. The real perf wins come from architecture choices (server components, code splitting, normalized data) not from library swap-outs.
Valtio is great if you prefer mutable proxies and don't need time-travel. MobX is mostly seen in legacy or class-heavy codebases now. Recoil is effectively unmaintained; if you're on it, plan a Jotai migration since the APIs are close enough that most of the work is mechanical.