Skip to main content

Recoil vs Jotai 2026: Atomic State Management Compared

·PkgPulse Team
0

TL;DR

Use Jotai. Recoil is effectively unmaintained. Recoil (~1.5M weekly downloads) was Meta's experiment in atomic state management — a genuinely good idea that solved a real React problem, but Meta never made it a priority. The last meaningful release was in 2023 and known React 18 concurrent mode bugs remain unfixed. Jotai (~3M downloads) implements the same atomic model, is under active development, has a ~5KB bundle size (versus Recoil's ~24KB), and has grown a rich ecosystem of utilities and integrations. If you are starting a new project or currently running Recoil, migrate to Jotai.

Key Takeaways

  • Jotai: ~3M weekly downloads — Recoil: ~1.5M (and declining)
  • Recoil's last major version: 0.7 (2023) — development has effectively stalled
  • Jotai is ~5x smaller — ~5KB gzipped vs Recoil's ~24KB
  • Both use the same atomic model — migration is mostly mechanical API renaming
  • Jotai has an active ecosystem — utilities, Immer integration, Zustand bridge, React Query integration
  • Recoil has known React 18 bugs that are unlikely to be fixed

The Atomic State Model

The atomic model solves a real React problem. Traditional global stores (Redux, Zustand) hold all state in one store object. Components subscribe to the whole store (or large slices of it) and re-render when anything in that slice changes.

Atoms are different: each piece of state lives in its own independent atom. Components subscribe to exactly the atoms they need. When an atom changes, only the components that depend on that atom re-render.

Traditional store model:
  Store
    ├── users: User[]        ← UserList and UserCount both re-render
    ├── posts: Post[]           when any user changes
    └── settings: Settings

Atomic model:
  usersAtom: User[]          ← UserList subscribes; re-renders when users change
  postsAtom: Post[]          ← PostList subscribes; re-renders when posts change
  settingsAtom: Settings     ← SettingsPanel subscribes; completely isolated

The key benefit is granular subscriptions: a component that uses settingsAtom never re-renders when usersAtom changes. This matters in large apps with many top-level state pieces.

Derived state (selectors) also fits naturally: a derived atom computes from other atoms and automatically updates when its dependencies change, without any subscription setup.


Why Recoil Has Problems

Recoil was introduced at React Europe 2020 and had a strong initial reception. The atomic model resonated with the React community. But Meta's internal priorities diverged from open-source maintenance:

Recoil development timeline:
  2020: Open sourced — great community reception
  2021: Active development, growing adoption
  2022: Releases slow down, community raises concerns
  2023: recoil@0.7 released — then silence
  2024: No releases; React 18 concurrent mode bugs accumulate
  2026: Still on 0.7, issues open for 2+ years, effectively unmaintained

The specific problems that have not been fixed:

  • Atom keys must be globally unique strings — a footgun that causes hard-to-debug production errors when code splitting is involved
  • Some useRecoilValue usages trigger extra re-renders in React 18 concurrent mode (StrictMode double-render issues)
  • No first-class TypeScript inference without explicit type annotations on every atom
  • The selector API has a steeper learning curve than it needs to be

The project is not officially deprecated — Meta still hosts the repository — but it is not receiving meaningful updates.


API Comparison

The APIs are similar enough that migration is mostly mechanical:

// Recoil — requires RecoilRoot provider + unique string keys
import { RecoilRoot, atom, selector, useRecoilState, useRecoilValue } from 'recoil';

// 1. Must wrap app in RecoilRoot
function App() {
  return <RecoilRoot><MyApp /></RecoilRoot>;
}

// 2. Define atoms — key must be globally unique
const cartItemsAtom = atom<CartItem[]>({
  key: 'cartItems',   // Unique string key required — duplicates cause runtime errors
  default: [],
});

// 3. Derived state uses a separate 'selector' concept
const cartTotalSelector = selector<number>({
  key: 'cartTotal',   // Also must be globally unique
  get: ({ get }) => {
    const items = get(cartItemsAtom);
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  },
});

// 4. Use in components
function CartSummary() {
  const [items, setItems] = useRecoilState(cartItemsAtom);
  const total = useRecoilValue(cartTotalSelector);
  return <div>Total: ${total.toFixed(2)} ({items.length} items)</div>;
}
// Jotai — no provider required, no string keys
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';

// 1. No provider needed for default global store
//    (use <Provider> only for isolated stores in tests or nested apps)

// 2. Define atoms — no key required, no strings to manage
const cartItemsAtom = atom<CartItem[]>([]);

// 3. Derived atoms are just atoms with a getter — no separate concept
const cartTotalAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});

// 4. Same hook names, nearly identical usage
function CartSummary() {
  const [items] = useAtom(cartItemsAtom);
  const total = useAtomValue(cartTotalAtom);
  return <div>Total: ${total.toFixed(2)} ({items.length} items)</div>;
}

The Jotai version is cleaner in several ways: no string keys to manage, no RecoilRoot required, and derived state is expressed as a plain atom rather than a separate selector abstraction.


Converting a Recoil Codebase to Jotai

The migration is largely mechanical. A codebase search-and-replace covers most of it:

// BEFORE (Recoil):
import { atom, selector, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';

const textAtom = atom({
  key: 'text',
  default: '',
});

const upperCaseSelector = selector({
  key: 'upperCaseText',
  get: ({ get }) => get(textAtom).toUpperCase(),
});

function TextInput() {
  const [text, setText] = useRecoilState(textAtom);
  const upper = useRecoilValue(upperCaseSelector);
  return <input value={text} onChange={e => setText(e.target.value)} />;
}
// AFTER (Jotai):
import { atom, useAtom, useAtomValue } from 'jotai';

// Remove { key, default: ... } wrapper — just pass the default value directly
const textAtom = atom('');

// selector → derived atom (atom with getter function)
const upperCaseAtom = atom((get) => get(textAtom).toUpperCase());

function TextInput() {
  // Hook names:
  // useRecoilState     → useAtom       (same shape: [value, setter])
  // useRecoilValue     → useAtomValue  (read-only)
  // useSetRecoilState  → useSetAtom    (write-only)
  const [text, setText] = useAtom(textAtom);
  const upper = useAtomValue(upperCaseAtom);
  return <input value={text} onChange={e => setText(e.target.value)} />;
}

// Provider changes:
// Remove <RecoilRoot> entirely — Jotai works without a provider
// OR replace with <Provider> (jotai) for isolated stores

The main non-mechanical parts of migration:

  • Atom families: Recoil's atomFamily → Jotai's atomFamily from jotai/utils (similar but slightly different signature)
  • Async selectors: Recoil's async selectors → Jotai's async atoms with Suspense (same pattern, cleaner API)
  • Snapshots: Recoil's useRecoilSnapshot has no direct equivalent in Jotai, but is rarely used

Jotai's Utility Library

Jotai ships a rich set of utilities in jotai/utils that cover patterns Recoil required manual implementation for:

// Persist atom to localStorage — syncs across browser tabs
import { atomWithStorage } from 'jotai/utils';

const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
// Read: synced from localStorage on mount
// Write: saves to localStorage, triggers storage event for other tabs

// Reset atom to its default value
import { atomWithReset, useResetAtom } from 'jotai/utils';

const filterAtom = atomWithReset('all');

function FilterReset() {
  const reset = useResetAtom(filterAtom);
  return <button onClick={reset}>Clear filters</button>;
}

// Loadable — handle async atoms without Suspense
import { loadable } from 'jotai/utils';

const userAtom = atom(async () => {
  const res = await fetch('/api/user');
  return res.json();
});

const loadableUserAtom = loadable(userAtom);

function UserDisplay() {
  const state = useAtomValue(loadableUserAtom);
  if (state.state === 'loading') return <Spinner />;
  if (state.state === 'hasError') return <Error />;
  return <div>{state.data.name}</div>;
}

Async Atoms and Suspense

Jotai's async atoms integrate naturally with React Suspense — a pattern Recoil partially supported but never cleanly resolved:

// Async atom — component suspends while loading
const userAtom = atom(async () => {
  const res = await fetch('/api/user');
  if (!res.ok) throw new Error('Failed to fetch user');
  return res.json() as Promise<User>;
});

// Write atom that makes API call
const updateUsernameAtom = atom(
  null,
  async (get, set, newUsername: string) => {
    const currentUser = await get(userAtom);
    await fetch(`/api/users/${currentUser.id}`, {
      method: 'PATCH',
      body: JSON.stringify({ username: newUsername }),
    });
    // Invalidate the user atom to trigger refetch
    set(userAtom, fetch('/api/user').then(r => r.json()));
  }
);

function UserProfile() {
  const user = useAtomValue(userAtom); // Suspends until resolved
  const updateUsername = useSetAtom(updateUsernameAtom);

  return (
    <div>
      <span>{user.name}</span>
      <button onClick={() => updateUsername('newname')}>Rename</button>
    </div>
  );
}

// Wrap in Suspense:
<Suspense fallback={<Spinner />}>
  <UserProfile />
</Suspense>

Jotai vs Zustand: Choosing the Right Atom Store

Both Jotai and Zustand are excellent, and both come from the same Pmndrs developer collective. The choice between them is conceptual:

DimensionJotaiZustand
Mental modelAtom-per-value (granular)Store-per-feature (grouped)
Re-render granularityPer-atomPer-selector
Global state organizationMany small atomsFew larger stores
Async stateFirst-class (async atoms)Async in actions
Best forComponent-adjacent stateFeature/domain state

If your state is naturally atomic — theme preference, modal open/closed, selected tab, hover states — Jotai fits perfectly. If your state is naturally grouped — all cart state together, all user state together — Zustand is the more natural fit.


Bundle Size

Recoil:  ~24KB gzipped  (version 0.7)
Jotai:   ~5KB gzipped   (core)
Zustand: ~1KB gzipped   (for comparison)

Jotai is ~5x smaller than Recoil — significant for performance-sensitive applications.

Package Health

PackageWeekly DownloadsSize (gzip)Latest VersionMaintained
jotai~3M~5KB2.xYes (active)
recoil~1.5M~24KB0.7 (2023)No (stalled)
zustand~10M~1KB5.xYes (active)
valtio~2M~3KB2.xYes (active)

Jotai is maintained by the Pmndrs collective (Poimandres), the same group behind Zustand, Valtio, and react-three-fiber. The team is active and maintains a responsive GitHub issue tracker.


When to Choose

Choose Jotai when:

  • Starting any new project that needs atomic state management
  • Currently on Recoil and experiencing React 18 issues or stale dependencies
  • You want atomic state management that is actively maintained
  • Bundle size matters (Jotai is ~5x smaller than Recoil)
  • You want async atoms with first-class Suspense support
  • You like the atom-per-value mental model

Consider Zustand instead of Jotai when:

  • Your state is better organized by feature/domain than by individual value
  • You want a simpler mental model — one store with methods
  • You don't need per-atom re-render granularity

Stay on Recoil only when:

  • You have a very large codebase with hundreds of atoms and migration cost is prohibitive
  • Everything works and you are not using React 18 concurrent mode features
  • You have significant internal tooling built around Recoil's snapshot API

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.