jotai, mobx, react-query, recoil, redux, unstated-next, and zustand are libraries designed to manage state in React applications, but they serve different purposes and follow distinct architectural philosophies. redux and zustand provide general-purpose global state management with varying levels of boilerplate and reactivity models. jotai and recoil offer atom-based state models that enable fine-grained reactivity and derived state. mobx uses observable objects and automatic tracking for reactive state updates. react-query specializes in server-state management—handling data fetching, caching, synchronization, and background updates—rather than client-side UI state. unstated-next (now deprecated) was a minimal wrapper around React Context aimed at simplifying its usage. These tools help developers avoid prop drilling, optimize re-renders, and structure complex state logic in scalable ways.
Managing state in React apps goes beyond useState. As apps grow, you face challenges like prop drilling, unnecessary re-renders, server-data synchronization, and complex state logic. The libraries in this comparison address these issues—but in fundamentally different ways. Let’s break down how they work, when to use them, and what trade-offs they involve.
redux: Predictable, Centralized State with Explicit UpdatesRedux enforces a unidirectional data flow: actions describe changes, reducers compute new state immutably, and the store holds everything. With Redux Toolkit (RTK), much of the old boilerplate is gone.
// redux + RTK
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 }
}
});
const store = configureStore({ reducer: counterSlice.reducer });
// In component
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
const count = useSelector(state => state.value);
const dispatch = useDispatch();
return <button onClick={() => dispatch(counterSlice.actions.increment())}>{count}</button>;
}
zustand: Hook-Based Store with Direct MutationsZustand skips providers and uses a single hook to access and update a global store. It supports partial updates and middleware.
// zustand
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>{count}</button>;
}
jotai: Atomic State with Fine-Grained ReactivityJotai uses atoms—small units of state—that can be read, written, and composed. Components only re-render when the atoms they use change.
// jotai
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
recoil: Atoms and Selectors for Derived StateRecoil also uses atoms but adds selectors for computed values. It builds a dependency graph to optimize renders.
// recoil
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';
const countAtom = atom({ key: 'count', default: 0 });
const doubleCount = selector({
key: 'doubleCount',
get: ({ get }) => get(countAtom) * 2
});
function Counter() {
const [count, setCount] = useRecoilState(countAtom);
const doubled = useRecoilValue(doubleCount);
return <div><button onClick={() => setCount(c => c + 1)}>{count}</button> → {doubled}</div>;
}
mobx: Observable Objects with Automatic TrackingMobX wraps your state in observables. When you read an observable inside a "reaction" (like a React component), it automatically tracks dependencies and re-renders when they change.
// mobx
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
class CounterStore {
count = 0;
constructor() { makeAutoObservable(this); }
increment = () => { this.count += 1; }
}
const store = new CounterStore();
const Counter = observer(() => (
<button onClick={store.increment}>{store.count}</button>
));
react-query: Server-State Management, Not UI StateReact Query treats server data as a cache. You define queries and mutations, and it handles fetching, caching, background updates, and error states.
// react-query
import { useQuery, useMutation, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return <QueryClientProvider client={queryClient}><UserProfile /></QueryClientProvider>;
}
function UserProfile() {
const { data, isLoading } = useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(res => res.json())
});
if (isLoading) return <div>Loading...</div>;
return <div>Hello, {data.name}!</div>;
}
unstated-next: Deprecated Context WrapperUnstated-Next was a thin layer over React Context to reduce boilerplate. It is now deprecated, and the author advises against new usage.
// unstated-next (DO NOT USE IN NEW PROJECTS)
import { createContainer } from 'unstated-next';
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
const CounterContainer = createContainer(useCounter);
function Counter() {
const { count, increment } = CounterContainer.useContainer();
return <button onClick={increment}>{count}</button>;
}
useSelector). Memoization is manual.react-redux.RecoilRoot provider. Adds conceptual overhead with atoms/selectors.observer wrapper or useObserver. Class-based stores can feel heavy.QueryClientProvider at the root. Zero config for basic use.All modern libraries handle async operations, but differently:
createAsyncThunk or middleware like Redux-Saga.atomWithQuery for React Query integration.useState patterns.The unstated-next package is officially deprecated. Its GitHub repo states: "This project is no longer maintained. Please use React Context directly or consider other state management libraries." Do not use it in new projects.
These libraries aren’t always mutually exclusive:
Example combining React Query and Zustand:
// Global UI state (modal open/closed)
const useUI = create((set) => ({
isModalOpen: false,
openModal: () => set({ isModalOpen: true })
}));
// Server data
const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
// Component uses both
function Profile() {
const { isModalOpen, openModal } = useUI();
const { data } = useQuery({ queryKey: ['profile'], queryFn: fetchProfile });
return <div>{data?.name} <button onClick={openModal}>Edit</button></div>;
}
| Library | Best For | Re-render Strategy | Providers Needed? | Async Support | Maintenance Status |
|---|---|---|---|---|---|
redux | Predictable, debuggable state | Selector-based | Yes (Provider) | Via middleware | Active |
zustand | Simple global state | Slice subscription | No | Direct | Active |
jotai | Fine-grained atomic state | Atom subscription | No | Async atoms | Active |
recoil | Large apps with derived state | Dependency graph | Yes (RecoilRoot) | Async selectors | Slowed |
mobx | Object-oriented mutable state | Automatic observable tracking | No (with observer) | Direct | Active |
react-query | Server data caching & sync | Query status changes | Yes (QueryClientProvider) | Built-in | Active |
unstated-next | — | Full context re-render | Yes | None | Deprecated |
unstated-next entirely—it’s outdated and unmaintained.Choose the tool that matches your team’s mental model and your app’s scale. Often, the simplest solution that covers your needs is the best one.
Choose jotai if you want a minimal, atomic state model that integrates deeply with React’s concurrent features and avoids unnecessary re-renders. It’s ideal for teams already comfortable with React hooks and seeking a lightweight alternative to Redux or Recoil without the overhead of context providers. Its atom-based approach scales well from small to large apps and supports async atoms and derived state out of the box.
Choose mobx if your team prefers an object-oriented, mutable state model with automatic reactivity based on tracked observables. It works well when you need to manage complex domain models that change frequently and want to avoid manual selector or reducer logic. However, be aware that MobX’s magic relies on proxies or decorators, which can complicate debugging or testing if not used carefully.
Choose react-query when your primary challenge is managing server-cached data—fetching, caching, refetching, and synchronizing with backend APIs. It should not be used for client-only UI state (like form inputs or modals). Pair it with a client-state library like Zustand or Jotai for full-stack state management. Its built-in features like stale-while-revalidate, pagination, and mutation rollback make it indispensable for data-heavy apps.
Choose recoil if you need a robust, atom-based state system with strong support for derived state, async selectors, and time-travel debugging. It’s backed by Meta and designed for large-scale applications, but adds complexity through its own mental model and dependency graph. Note that active development has slowed, so evaluate long-term maintenance risks before adopting in new projects.
Choose redux if you require strict predictability, middleware extensibility (e.g., logging, undo/redo), or integration with Redux DevTools for time-travel debugging. Modern Redux (with Redux Toolkit) reduces boilerplate significantly, but it still involves more setup than alternatives. Best suited for large teams or apps where state consistency and auditability are critical, though often overkill for simple use cases.
Do not choose unstated-next for new projects—it is officially deprecated and no longer maintained. The author recommends using React Context directly or switching to more capable solutions like Zustand or Jotai. While it once offered a cleaner API over raw Context, its limitations (no cross-context composition, poor performance with frequent updates) make it unsuitable for modern React applications.
Choose zustand if you want a simple, hook-based global state store with minimal boilerplate, no context providers, and excellent performance due to selective subscriptions. It’s ideal for most medium-sized apps where you need shared state without the complexity of Redux or the learning curve of MobX. Its middleware support and TypeScript readiness make it a pragmatic default choice for many teams.

visit jotai.org or npm i jotai
Jotai scales from a simple useState replacement to an enterprise TypeScript application.
An atom represents a piece of state. All you need is to specify an initial value, which can be primitive values like strings and numbers, objects, and arrays. You can create as many primitive atoms as you want.
import { atom } from 'jotai'
const countAtom = atom(0)
const countryAtom = atom('Japan')
const citiesAtom = atom(['Tokyo', 'Kyoto', 'Osaka'])
const mangaAtom = atom({ 'Dragon Ball': 1984, 'One Piece': 1997, Naruto: 1999 })
It can be used like React.useState:
import { useAtom } from 'jotai'
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<h1>
{count}
<button onClick={() => setCount((c) => c + 1)}>one up</button>
...
A new read-only atom can be created from existing atoms by passing a read
function as the first argument. get allows you to fetch the contextual value
of any atom.
const doubledCountAtom = atom((get) => get(countAtom) * 2)
function DoubleCounter() {
const [doubledCount] = useAtom(doubledCountAtom)
return <h2>{doubledCount}</h2>
}
You can combine multiple atoms to create a derived atom.
const count1 = atom(1)
const count2 = atom(2)
const count3 = atom(3)
const sum = atom((get) => get(count1) + get(count2) + get(count3))
Or if you like fp patterns ...
const atoms = [count1, count2, count3, ...otherAtoms]
const sum = atom((get) => atoms.map(get).reduce((acc, count) => acc + count))
You can make the read function an async function too.
const urlAtom = atom('https://json.host.com')
const fetchUrlAtom = atom(async (get) => {
const response = await fetch(get(urlAtom))
return await response.json()
})
function Status() {
// Re-renders the component after urlAtom is changed and the async function above concludes
const [json] = useAtom(fetchUrlAtom)
...
Specify a write function at the second argument. get will return the current
value of an atom. set will update the value of an atom.
const decrementCountAtom = atom(
(get) => get(countAtom),
(get, set, _arg) => set(countAtom, get(countAtom) - 1)
)
function Counter() {
const [count, decrement] = useAtom(decrementCountAtom)
return (
<h1>
{count}
<button onClick={decrement}>Decrease</button>
...
Just do not define a read function.
const multiplyCountAtom = atom(null, (get, set, by) =>
set(countAtom, get(countAtom) * by),
)
function Controls() {
const [, multiply] = useAtom(multiplyCountAtom)
return <button onClick={() => multiply(3)}>triple</button>
}
Just make the write function an async function and call set when you're ready.
const fetchCountAtom = atom(
(get) => get(countAtom),
async (_get, set, url) => {
const response = await fetch(url)
set(countAtom, (await response.json()).count)
}
)
function Controls() {
const [count, compute] = useAtom(fetchCountAtom)
return (
<button onClick={() => compute('http://count.host.com')}>compute</button>
...
Jotai's fluid interface is no accident — atoms are monads, just like promises! Monads are an established pattern for modular, pure, robust and understandable code which is optimized for change. Read more about Jotai and monads.