Recoil vs Jotai 2026: Atomic State Management Compared
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
useRecoilValueusages 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
selectorAPI 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'satomFamilyfromjotai/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
useRecoilSnapshothas 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:
| Dimension | Jotai | Zustand |
|---|---|---|
| Mental model | Atom-per-value (granular) | Store-per-feature (grouped) |
| Re-render granularity | Per-atom | Per-selector |
| Global state organization | Many small atoms | Few larger stores |
| Async state | First-class (async atoms) | Async in actions |
| Best for | Component-adjacent state | Feature/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
| Package | Weekly Downloads | Size (gzip) | Latest Version | Maintained |
|---|---|---|---|---|
| jotai | ~3M | ~5KB | 2.x | Yes (active) |
| recoil | ~1.5M | ~24KB | 0.7 (2023) | No (stalled) |
| zustand | ~10M | ~1KB | 5.x | Yes (active) |
| valtio | ~2M | ~3KB | 2.x | Yes (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
Related Resources
- Compare Recoil and Jotai download trends: /compare/recoil-vs-jotai
- Redux Toolkit vs Zustand — the other major React state options: /blog/redux-toolkit-vs-zustand-2026
- Jotai package details and health metrics: /packages/jotai
See the live comparison
View recoil vs. jotai on PkgPulse →