Jotai

Primitive and flexible atomic state management for React

Jotai - Primitive and Flexible State

Jotai (Japanese for "state") takes an atomic approach to React state management. Instead of a single store, you create small, independent pieces of state called atoms. Components subscribe only to the atoms they use, providing optimal re-renders out of the box.

Why Jotai?

  • Atomic — State split into independent atoms
  • Minimal API — Just atom() and useAtom()
  • No Providers — Works without wrapping your app
  • Derived State — Easy computed values
  • TypeScript — Full type inference

Installation

npm install jotai

Basic Atoms

import { atom, useAtom } from 'jotai';

// Create atoms - primitive pieces of state
const countAtom = atom(0);
const nameAtom = atom('');
const darkModeAtom = atom(false);

// Use atoms in components
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </div>
  );
}

// Read-only hook for performance
import { useAtomValue, useSetAtom } from 'jotai';

function Display() {
  // Only subscribes to read
  const count = useAtomValue(countAtom);
  return <span>{count}</span>;
}

function Controls() {
  // Only gets setter, never re-renders from count changes
  const setCount = useSetAtom(countAtom);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Increment
    </button>
  );
}

Derived Atoms

import { atom, useAtomValue } from 'jotai';

// Primitive atoms
const priceAtom = atom(100);
const quantityAtom = atom(1);
const taxRateAtom = atom(0.08);

// Read-only derived atom
const subtotalAtom = atom((get) => {
  const price = get(priceAtom);
  const quantity = get(quantityAtom);
  return price * quantity;
});

const taxAtom = atom((get) => {
  const subtotal = get(subtotalAtom);
  const taxRate = get(taxRateAtom);
  return subtotal * taxRate;
});

const totalAtom = atom((get) => {
  return get(subtotalAtom) + get(taxAtom);
});

// Usage
function PriceSummary() {
  const subtotal = useAtomValue(subtotalAtom);
  const tax = useAtomValue(taxAtom);
  const total = useAtomValue(totalAtom);
  
  return (
    <div>
      <p>Subtotal: ${subtotal.toFixed(2)}</p>
      <p>Tax: ${tax.toFixed(2)}</p>
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

Writable Derived Atoms

import { atom, useAtom } from 'jotai';

const celsiusAtom = atom(25);

// Read-write derived atom
const fahrenheitAtom = atom(
  // Getter
  (get) => get(celsiusAtom) * 9/5 + 32,
  // Setter
  (get, set, newFahrenheit) => {
    const celsius = (newFahrenheit - 32) * 5/9;
    set(celsiusAtom, celsius);
  }
);

function TemperatureConverter() {
  const [celsius, setCelsius] = useAtom(celsiusAtom);
  const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom);
  
  return (
    <div>
      <label>
        Celsius:
        <input 
          type="number" 
          value={celsius}
          onChange={(e) => setCelsius(Number(e.target.value))}
        />
      </label>
      <label>
        Fahrenheit:
        <input 
          type="number" 
          value={fahrenheit}
          onChange={(e) => setFahrenheit(Number(e.target.value))}
        />
      </label>
    </div>
  );
}

Todo App Example

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';

// Atom for the todo list
const todosAtom = atom([]);

// Derived atoms
const todoCountAtom = atom((get) => get(todosAtom).length);
const completedCountAtom = atom((get) => 
  get(todosAtom).filter(t => t.completed).length
);

// Action atoms (write-only)
const addTodoAtom = atom(null, (get, set, text) => {
  set(todosAtom, (prev) => [...prev, {
    id: Date.now(),
    text,
    completed: false,
  }]);
});

const toggleTodoAtom = atom(null, (get, set, id) => {
  set(todosAtom, (prev) => prev.map(todo =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  ));
});

const deleteTodoAtom = atom(null, (get, set, id) => {
  set(todosAtom, (prev) => prev.filter(todo => todo.id !== id));
});

// Components
function AddTodo() {
  const [text, setText] = useState('');
  const addTodo = useSetAtom(addTodoAtom);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      addTodo(text);
      setText('');
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button type="submit">Add</button>
    </form>
  );
}

function TodoList() {
  const todos = useAtomValue(todosAtom);
  const toggleTodo = useSetAtom(toggleTodoAtom);
  const deleteTodo = useSetAtom(deleteTodoAtom);
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          />
          {todo.text}
          <button onClick={() => deleteTodo(todo.id)}>×</button>
        </li>
      ))}
    </ul>
  );
}

function Stats() {
  const total = useAtomValue(todoCountAtom);
  const completed = useAtomValue(completedCountAtom);
  
  return <p>{completed} of {total} completed</p>;
}

Async Atoms

import { atom, useAtomValue } from 'jotai';
import { Suspense } from 'react';

// Async atom - returns a promise
const userAtom = atom(async () => {
  const response = await fetch('/api/user');
  return response.json();
});

// Derived async atom
const userPostsAtom = atom(async (get) => {
  const user = await get(userAtom);
  const response = await fetch(`/api/users/${user.id}/posts`);
  return response.json();
});

// Use with Suspense
function UserProfile() {
  const user = useAtomValue(userAtom);
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  );
}

Atom with Storage

import { atomWithStorage } from 'jotai/utils';

// Automatically syncs with localStorage
const themeAtom = atomWithStorage('theme', 'light');
const userPrefsAtom = atomWithStorage('userPrefs', {
  notifications: true,
  language: 'en',
});

function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);
  
  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  );
}

Jotai vs Zustand

Aspect Jotai Zustand
Mental model Atoms (bottom-up) Store (top-down)
State structure Distributed atoms Single object
Re-renders Per-atom subscriptions Selector-based
Derived state Built-in atoms get() in store

💡 Best Practices

  • • Keep atoms small and focused
  • • Use derived atoms for computed values
  • • Use useAtomValue/useSetAtom for read-only/write-only access
  • • Organize atoms by feature or domain
  • • Use atomWithStorage for persistent state