jotai vs mobx vs react-query vs recoil vs redux vs unstated-next vs zustand
State Management and Data Fetching Solutions for React Applications
jotaimobxreact-queryrecoilreduxunstated-nextzustandSimilar Packages:

State Management and Data Fetching Solutions for React Applications

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.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
jotai021,069519 kB611 days agoMIT
mobx028,1844.35 MB806 months agoMIT
react-query048,8712.26 MB1473 years agoMIT
recoil019,5202.21 MB3223 years agoMIT
redux061,463290 kB412 years agoMIT
unstated-next0---7 years agoMIT
zustand057,47095 kB45 days agoMIT

State Management Deep Dive: Jotai, MobX, React Query, Recoil, Redux, Unstated-Next, and Zustand

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.

🧠 Core Philosophy: How Each Library Thinks About State

redux: Predictable, Centralized State with Explicit Updates

Redux 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 Mutations

Zustand 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 Reactivity

Jotai 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 State

Recoil 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 Tracking

MobX 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 State

React 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 Wrapper

Unstated-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>;
}

🔁 Re-render Behavior: What Triggers Updates?

  • Redux: Components re-render if the part of state they select changes (via useSelector). Memoization is manual.
  • Zustand: Uses reference equality checks. Only components using changed slices re-render.
  • Jotai: Atom-based subscriptions mean only components reading a specific atom re-render when it changes.
  • Recoil: Builds a dependency graph; only components depending on changed atoms/selectors re-render.
  • MobX: Automatically tracks which observables a component reads and re-renders only when those change.
  • React Query: Re-renders when query status (loading, error, data) changes. Does not manage UI state.
  • Unstated-Next: Re-renders all consumers of a context whenever any part of the context value changes (same as raw Context).

📦 Bundle Size and Setup Complexity

  • Redux: Moderate setup (store, slices, provider), but RTK simplifies it. Requires react-redux.
  • Zustand: Minimal setup—just create a store and use the hook. No providers needed.
  • Jotai: Very lightweight. Just import atoms and hooks.
  • Recoil: Requires a RecoilRoot provider. Adds conceptual overhead with atoms/selectors.
  • MobX: Needs observer wrapper or useObserver. Class-based stores can feel heavy.
  • React Query: Requires a QueryClientProvider at the root. Zero config for basic use.
  • Unstated-Next: Simple, but obsolete.

🔄 Async and Derived State Support

All modern libraries handle async operations, but differently:

  • Redux: Use createAsyncThunk or middleware like Redux-Saga.
  • Zustand: Update state inside async functions directly.
  • Jotai: Async atoms can return promises; use atomWithQuery for React Query integration.
  • Recoil: Async selectors handle data fetching with built-in caching.
  • MobX: Async actions can mutate state freely after awaiting.
  • React Query: Built for async—queries, mutations, infinite loading, etc.
  • Unstated-Next: No special async support; just use useState patterns.

⚠️ Deprecation Notice: Unstated-Next

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.

🤝 Complementary Use Cases

These libraries aren’t always mutually exclusive:

  • React Query + Zustand/Jotai: Use React Query for server data and Zustand/Jotai for client UI state.
  • Redux + RTK Query: RTK Query (part of Redux Toolkit) brings React Query-like features into Redux.
  • MobX + MST: MobX State Tree adds structure and snapshots on top of MobX.

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>;
}

📊 Summary Table

LibraryBest ForRe-render StrategyProviders Needed?Async SupportMaintenance Status
reduxPredictable, debuggable stateSelector-basedYes (Provider)Via middlewareActive
zustandSimple global stateSlice subscriptionNoDirectActive
jotaiFine-grained atomic stateAtom subscriptionNoAsync atomsActive
recoilLarge apps with derived stateDependency graphYes (RecoilRoot)Async selectorsSlowed
mobxObject-oriented mutable stateAutomatic observable trackingNo (with observer)DirectActive
react-queryServer data caching & syncQuery status changesYes (QueryClientProvider)Built-inActive
unstated-nextFull context re-renderYesNoneDeprecated

💡 Final Guidance

  • Need to manage form state, modals, or theme settings? → Use Zustand or Jotai.
  • Building a data dashboard with lots of API calls? → Use React Query for server state, plus Zustand for UI state.
  • Working in a large team that values traceability?Redux with RTK gives you DevTools and clear action logs.
  • Prefer mutable objects and automatic updates?MobX feels natural if you come from OOP.
  • Avoid 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.

How to Choose: jotai vs mobx vs react-query vs recoil vs redux vs unstated-next vs zustand

  • jotai:

    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.

  • mobx:

    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.

  • react-query:

    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.

  • recoil:

    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.

  • redux:

    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.

  • unstated-next:

    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.

  • zustand:

    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.

README for jotai


Jotai (light mode)


visit jotai.org or npm i jotai

Build Status Build Size Version Downloads Discord Shield Open Collective

Jotai scales from a simple useState replacement to an enterprise TypeScript application.

  • Minimal core API (2kb)
  • Many utilities and extensions
  • No string keys (compared to Recoil)

Examples: Demo 1 | Demo 2

First, create a primitive atom

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 })

Use the atom in your components

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

Create derived atoms with computed values

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>
}

Creating an atom from multiple atoms

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))

Derived async atoms needs suspense

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

You can create a writable derived atom

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

Write only derived atoms

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>
}

Async actions

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

Note about functional programming

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.

Links