ReactJotai Atoms

Jotai Atoms

Jotai takes a fundamentally different approach to state management. Instead of one global store, state is broken into individual atoms — tiny, independent pieces of state. Components subscribe to exactly the atoms they need, which means re-renders are surgical: updating one atom only re-renders components that read that atom.

Install Jotai with:

Bash
npm install jotai
Creating and Reading Atoms

An atom is created with atom(initialValue). Use useAtom in a component to get a [value, setValue] pair — exactly like useState, but the state lives outside the component and is shared across the entire app:

TS
import { atom, useAtom } from 'jotai'

// Define atoms at module scope — they are singletons
export const countAtom = atom(0)
export const nameAtom = atom('Alice')

TSX
import { useAtom } from 'jotai'
import { countAtom } from './atoms'

function Counter() {
  const [count, setCount] = useAtom(countAtom)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
      <button onClick={() => setCount((c) => c - 1)}>-</button>
    </div>
  )
}

// This component reads the SAME countAtom — no prop drilling needed
function CountDisplay() {
  const [count] = useAtom(countAtom)
  return <span>Current: {count}</span>
}
Note
Jotai requires no Provider by default. It uses a built-in global store. You can optionally add a <Provider> to create isolated atom scopes (useful for testing or server rendering).
useAtomValue and useSetAtom

When a component only reads or only writes an atom, use the more focused hooks to avoid unnecessary re-renders:

TSX
import { useAtomValue, useSetAtom } from 'jotai'
import { countAtom } from './atoms'

// Only re-renders when countAtom changes
function ReadOnlyDisplay() {
  const count = useAtomValue(countAtom)
  return <p>The count is {count}</p>
}

// NEVER re-renders due to countAtom changes
function IncrementButton() {
  const setCount = useSetAtom(countAtom)
  return <button onClick={() => setCount((c) => c + 1)}>Increment</button>
}
Tip
Prefer useSetAtom for pure write components like buttons and form submitters. They hold a stable reference to the setter and never re-render when the atom's value changes.
Derived Atoms

Pass a function to atom() to create a derived atom — one whose value is computed from other atoms. The function receives a get utility to read any atom. Derived atoms are read-only by default:

TS
import { atom } from 'jotai'

export const countAtom = atom(0)
export const multiplierAtom = atom(2)

// Derived: automatically recomputes when countAtom or multiplierAtom changes
export const resultAtom = atom((get) => get(countAtom) * get(multiplierAtom))

// Multiple dependencies are fine
export const summaryAtom = atom(
  (get) => `Count is ${get(countAtom)}, result is ${get(resultAtom)}`
)

TSX
function ResultDisplay() {
  const result = useAtomValue(resultAtom)
  const summary = useAtomValue(summaryAtom)

  return (
    <div>
      <p>Result: {result}</p>
      <p>{summary}</p>
    </div>
  )
}
Read-Write Derived Atoms

Provide both a read function and a write function to create a two-way derived atom — useful for transformations or form values:

TS
// Temperature in Celsius stored as source of truth
const celsiusAtom = atom(0)

// Derived atom that converts to/from Fahrenheit
const fahrenheitAtom = atom(
  (get) => get(celsiusAtom) * 1.8 + 32,
  (_get, set, fahrenheit: number) => {
    set(celsiusAtom, (fahrenheit - 32) / 1.8)
  }
)

function TemperatureConverter() {
  const [celsius, setCelsius] = useAtom(celsiusAtom)
  const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom)

  return (
    <div>
      <input
        type="number"
        value={celsius}
        onChange={(e) => setCelsius(Number(e.target.value))}
        placeholder="Celsius"
      />
      <input
        type="number"
        value={fahrenheit}
        onChange={(e) => setFahrenheit(Number(e.target.value))}
        placeholder="Fahrenheit"
      />
    </div>
  )
}
Async Atoms

An atom whose read function returns a Promise is an async atom. Reading it suspends the component until the Promise resolves — use React Suspense to handle the loading state:

TS
import { atom } from 'jotai'

const userIdAtom = atom(1)

// This atom fetches automatically when userIdAtom changes
const userAtom = atom(async (get) => {
  const id = get(userIdAtom)
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
})

TSX
import { Suspense } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'

function UserProfile() {
  // Suspends while the async atom resolves
  const user = useAtomValue(userAtom)
  return <p>{user.name} — {user.email}</p>
}

function UserIdPicker() {
  const setUserId = useSetAtom(userIdAtom)
  return (
    <div>
      {[1, 2, 3].map((id) => (
        <button key={id} onClick={() => setUserId(id)}>User {id}</button>
      ))}
    </div>
  )
}

function App() {
  return (
    <div>
      <UserIdPicker />
      <Suspense fallback={<p>Loading user…</p>}>
        <UserProfile />
      </Suspense>
    </div>
  )
}
atomFamily — Parameterized Atoms

atomFamily creates a factory that returns a unique atom per parameter value. This is ideal for per-entity state — each item in a list gets its own atom:

TS
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'

// One atom per todo ID
const todoAtomFamily = atomFamily((id: number) =>
  atom({ id, text: '', completed: false })
)

// Usage: todoAtomFamily(1), todoAtomFamily(2) — each is independent
Complete Todos App

TS
import { atom } from 'jotai'

interface Todo {
  id: number
  text: string
  completed: boolean
}

export const todosAtom = atom<Todo[]>([
  { id: 1, text: 'Learn Jotai', completed: false },
  { id: 2, text: 'Build something', completed: false },
])

export const filterAtom = atom<'all' | 'active' | 'done'>('all')

export const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom)
  const filter = get(filterAtom)
  if (filter === 'active') return todos.filter((t) => !t.completed)
  if (filter === 'done') return todos.filter((t) => t.completed)
  return todos
})

export const completedCountAtom = atom(
  (get) => get(todosAtom).filter((t) => t.completed).length
)

TSX
import { useAtom, useAtomValue, useSetAtom } from 'jotai'

function TodoList() {
  const filtered = useAtomValue(filteredTodosAtom)
  const [todos, setTodos] = useAtom(todosAtom)
  const [filter, setFilter] = useAtom(filterAtom)

  const toggle = (id: number) =>
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    )

  const add = (text: string) =>
    setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }])

  return (
    <div>
      <div>
        {(['all', 'active', 'done'] as const).map((f) => (
          <button key={f} onClick={() => setFilter(f)}
            style={{ fontWeight: filter === f ? 'bold' : 'normal' }}>
            {f}
          </button>
        ))}
      </div>
      <ul>
        {filtered.map((todo) => (
          <li key={todo.id} onClick={() => toggle(todo.id)}
            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  )
}
Jotai vs Zustand — When to Choose Each

Aspect

Jotai

Zustand

Mental model

Individual atoms (bottom-up)

Single store (top-down)

Re-render granularity

Per-atom (very fine)

Per-selector

Suspense integration

First-class (async atoms)

Requires manual handling

Per-entity state

Natural (atomFamily)

Requires careful selectors

Bundle size

~3 kB

~3 kB

DevTools

Jotai DevTools extension

Redux DevTools via middleware

Provider required

No (optional)

No

  • Use Jotai when state is naturally atomic — UI toggles, per-item selection, async data per entity.

  • Use Zustand when state is a cohesive object — a cart, a user session, a complex form.

  • Jotai pairs naturally with React Suspense for async data — async atoms compose cleanly.

  • Both libraries are tiny and work without a Provider, so choosing the wrong one is not a disaster.

Warning
Do not mix Jotai and Zustand to manage the same piece of state. Pick one library per concern. Using both libraries in the same app is fine as long as they manage separate domains.