Recoil Overview
Recoil is an atomic state management library built by Facebook (Meta) for React. Like Jotai, it models state as individual atoms, but it adds a richer selector API, first-class Suspense support for async data, and atomFamily/selectorFamily for parameterized state. If you have used React's built-in state before, the mental model feels natural.
Install Recoil with:
npm install recoil
RecoilRoot — Required Provider
Unlike Zustand and Jotai, Recoil requires a RecoilRoot provider near the top of your tree. Place it in your root layout:
import { RecoilRoot } from 'recoil'
function App() {
return (
<RecoilRoot>
{/* All components that use Recoil must be inside RecoilRoot */}
<MyApp />
</RecoilRoot>
)
}Atoms — State Units
An atom holds a single piece of state. Every atom needs a globally unique key. Components that subscribe to an atom re-render when it changes:
import { atom } from 'recoil'
export const textAtom = atom({
key: 'textAtom', // unique across the app
default: '',
})
export const todoListAtom = atom<Todo[]>({
key: 'todoListAtom',
default: [],
})Reading and Writing Atoms
Recoil provides three hooks that mirror the atom read/write split:
useRecoilState(atom)— returns[value, setter], likeuseState. Component re-renders on change.useRecoilValue(atom)— read-only. Re-renders on change.useSetRecoilState(atom)— write-only. Does not re-render when the atom changes.
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { textAtom } from './atoms'
function TextInput() {
const [text, setText] = useRecoilState(textAtom)
return (
<input value={text} onChange={(e) => setText(e.target.value)} />
)
}
function TextDisplay() {
const text = useRecoilValue(textAtom) // read-only
return <p>You typed: {text}</p>
}
function ClearButton() {
const setText = useSetRecoilState(textAtom) // no re-render on change
return <button onClick={() => setText('')}>Clear</button>
}Selectors — Derived State
A selector computes a value derived from one or more atoms. Selectors are automatically re-evaluated when their dependencies change:
import { atom, selector } from 'recoil'
export const todoListAtom = atom<Todo[]>({
key: 'todoListAtom',
default: [],
})
export const todoFilterAtom = atom<'all' | 'active' | 'done'>({
key: 'todoFilterAtom',
default: 'all',
})
export const filteredTodosSelector = selector({
key: 'filteredTodosSelector',
get: ({ get }) => {
const todos = get(todoListAtom)
const filter = get(todoFilterAtom)
switch (filter) {
case 'active': return todos.filter((t) => !t.completed)
case 'done': return todos.filter((t) => t.completed)
default: return todos
}
},
})
export const todoStatsSelector = selector({
key: 'todoStatsSelector',
get: ({ get }) => {
const todos = get(todoListAtom)
return {
total: todos.length,
completed: todos.filter((t) => t.completed).length,
remaining: todos.filter((t) => !t.completed).length,
}
},
})Async Selectors with Suspense
Selectors can return a Promise. React's Suspense handles the loading state automatically — no manual loading flags:
import { atom, selector } from 'recoil'
const selectedUserIdAtom = atom({ key: 'selectedUserIdAtom', default: 1 })
const userSelector = selector({
key: 'userSelector',
get: async ({ get }) => {
const id = get(selectedUserIdAtom)
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
if (!res.ok) throw new Error('User not found')
return res.json()
},
})import { Suspense } from 'react'
import { useRecoilValue } from 'recoil'
function UserCard() {
// Suspends while the selector's Promise resolves
const user = useRecoilValue(userSelector)
return <div>{user.name} — {user.email}</div>
}
function UserView() {
return (
<Suspense fallback={<p>Loading user…</p>}>
<UserCard />
</Suspense>
)
}atomFamily and selectorFamily
atomFamily creates a parameterized atom factory. Instead of defining one atom per item, you define a template and Recoil creates atom instances on demand:
import { atomFamily, selectorFamily } from 'recoil'
// One atom per todo ID
const todoItemAtom = atomFamily({
key: 'todoItemAtom',
default: (id: number) => ({ id, text: '', completed: false }),
})
// One selector per todo ID
const todoItemLabelSelector = selectorFamily({
key: 'todoItemLabelSelector',
get: (id: number) => ({ get }) => {
const todo = get(todoItemAtom(id))
return todo.completed ? `✓ ${todo.text}` : todo.text
},
})
// Usage inside a component:
function TodoItem({ id }: { id: number }) {
const [todo, setTodo] = useRecoilState(todoItemAtom(id))
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodo((t) => ({ ...t, completed: !t.completed }))}
/>
{todo.text}
</li>
)
}Complete Filterable Todo List
import {
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil'
import { todoListAtom, todoFilterAtom, filteredTodosSelector, todoStatsSelector } from './atoms'
function AddTodo() {
const setTodos = useSetRecoilState(todoListAtom)
const [input, setInput] = React.useState('')
const add = () => {
if (!input.trim()) return
setTodos((prev) => [
...prev,
{ id: Date.now(), text: input, completed: false },
])
setInput('')
}
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={add}>Add</button>
</div>
)
}
function TodoList() {
const todos = useRecoilValue(filteredTodosSelector)
const setTodos = useSetRecoilState(todoListAtom)
const toggle = (id: number) =>
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
)
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => toggle(todo.id)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</li>
))}
</ul>
)
}
function TodoStats() {
const stats = useRecoilValue(todoStatsSelector)
return <p>{stats.remaining} remaining / {stats.total} total</p>
}
function FilterBar() {
const [filter, setFilter] = useRecoilState(todoFilterAtom)
return (
<div>
{(['all', 'active', 'done'] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}>
{f}
</button>
))}
</div>
)
}Recoil's Current Status
Recoil was open-sourced by Meta in 2020 and reached version 0.7.x. As of 2024, active development has slowed significantly — the repository has had fewer releases, and the team behind it at Meta has shrunk. Recoil is stable for existing projects but new projects should weigh the reduced maintenance trajectory.
Existing Recoil apps: continue using it — the API is stable and breaking changes are rare.
New projects: consider Jotai as a community-maintained alternative with a nearly identical atoms model, better TypeScript support, and active development.
Jotai is deliberately API-compatible in spirit — migration is usually mechanical.
If you need React Suspense integration with atomic state, Jotai is currently the stronger choice.