ReactRecoil Overview

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:

Bash
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:

TSX
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:

TS
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], like useState. 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.

TSX
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:

TS
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:

TS
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()
  },
})

TSX
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:

TS
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

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

Note
Recoil's selector API is more mature than Jotai's derived atoms in a few edge cases (e.g. write-through selectors). If your app relies heavily on complex selectors, audit those specific patterns before migrating.
Warning
The unique key string requirement in Recoil (key: 'myAtom') is a footgun in large codebases — duplicate keys throw runtime errors. Jotai avoids this entirely by using object identity instead of string keys.
Tip
If you are evaluating Recoil vs Jotai for a new project, start a small proof-of-concept with Jotai. The atomic model is identical and the migration path if you later need Recoil-specific features is short.