Jotai Complete Guide | Primitive Flexible React State Management

Jotai Complete Guide | Primitive Flexible React State Management

이 글의 핵심

Jotai is a primitive and flexible state management library for React. It takes an atomic approach to global state with a minimal API inspired by Recoil.

Introduction

Jotai is a primitive and flexible state management solution for React. It takes an atomic approach to global React state, inspired by Recoil with a more minimal API.

The Problem

Context API:

const UserContext = createContext();

function App() {
  const [user, setUser] = useState({ name: 'Alice', age: 30 });
  
  return (
    <UserContext.Provider value={user}>
      <ComponentA /> {/* Re-renders on ANY user change */}
      <ComponentB /> {/* Re-renders on ANY user change */}
    </UserContext.Provider>
  );
}

Jotai:

const nameAtom = atom('Alice');
const ageAtom = atom(30);

function ComponentA() {
  const [name] = useAtom(nameAtom);
  return <div>{name}</div>; // Only re-renders when name changes
}

function ComponentB() {
  const [age] = useAtom(ageAtom);
  return <div>{age}</div>; // Only re-renders when age changes
}

1. Installation

npm install jotai

2. Atoms (Primitive State)

Basic Atom

import { atom, useAtom } from 'jotai';

// Define atom
const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(c => c + 1)}>Increment (updater)</button>
    </div>
  );
}

Read-only Hook

import { useAtomValue } from 'jotai';

function Display() {
  const count = useAtomValue(countAtom); // Read-only
  return <div>{count}</div>;
}

Write-only Hook

import { useSetAtom } from 'jotai';

function Controls() {
  const setCount = useSetAtom(countAtom); // Write-only
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Increment
    </button>
  );
}

3. Derived Atoms

import { atom } from 'jotai';

const priceAtom = atom(100);
const quantityAtom = atom(2);

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

function Cart() {
  const [price, setPrice] = useAtom(priceAtom);
  const [quantity, setQuantity] = useAtom(quantityAtom);
  const total = useAtomValue(totalAtom);
  
  return (
    <div>
      <input value={price} onChange={e => setPrice(+e.target.value)} />
      <input value={quantity} onChange={e => setQuantity(+e.target.value)} />
      <p>Total: ${total}</p>
    </div>
  );
}

Writable Derived Atoms

const celsiusAtom = atom(0);

const fahrenheitAtom = atom(
  (get) => get(celsiusAtom) * 9/5 + 32, // Read
  (get, set, newValue) => {             // Write
    set(celsiusAtom, (newValue - 32) * 5/9);
  }
);

function Temperature() {
  const [celsius, setCelsius] = useAtom(celsiusAtom);
  const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom);
  
  return (
    <div>
      <input value={celsius} onChange={e => setCelsius(+e.target.value)} />°C
      <input value={fahrenheit} onChange={e => setFahrenheit(+e.target.value)} />°F
    </div>
  );
}

4. Async Atoms

const userIdAtom = atom(1);

const userAtom = atom(async (get) => {
  const id = get(userIdAtom);
  const res = await fetch(`/api/users/${id}`);
  return res.json();
});

function UserProfile() {
  const [user] = useAtom(userAtom);
  
  return <div>{user.name}</div>;
}

With Suspense

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

5. Actions (Write-only Atoms)

const todosAtom = atom([]);

const addTodoAtom = atom(
  null, // No read
  (get, set, text: string) => {
    const todos = get(todosAtom);
    set(todosAtom, [...todos, { id: Date.now(), text, done: false }]);
  }
);

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

6. Atom Families

import { atomFamily } from 'jotai/utils';

const userAtomFamily = atomFamily((id: number) =>
  atom(async () => {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  })
);

function UserProfile({ userId }: { userId: number }) {
  const [user] = useAtom(userAtomFamily(userId));
  return <div>{user.name}</div>;
}

7. Utils

atomWithStorage

import { atomWithStorage } from 'jotai/utils';

const darkModeAtom = atomWithStorage('darkMode', false);

function ThemeToggle() {
  const [darkMode, setDarkMode] = useAtom(darkModeAtom);
  
  return (
    <button onClick={() => setDarkMode(!darkMode)}>
      {darkMode ? 'Light' : 'Dark'} Mode
    </button>
  );
}

atomWithReducer

import { atomWithReducer } from 'jotai/utils';

const countReducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    default: return state;
  }
};

const countAtom = atomWithReducer(0, countReducer);

function Counter() {
  const [count, dispatch] = useAtom(countAtom);
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  );
}

atomWithReset

import { atomWithReset, useResetAtom } from 'jotai/utils';

const filterAtom = atomWithReset('');

function SearchFilter() {
  const [filter, setFilter] = useAtom(filterAtom);
  const resetFilter = useResetAtom(filterAtom);
  
  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <button onClick={resetFilter}>Clear</button>
    </div>
  );
}

8. Real-World Example: Todo App

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

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

const todosAtom = atomWithStorage<Todo[]>('todos', []);

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

const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);
  
  if (filter === 'all') return todos;
  return todos.filter(t => filter === 'done' ? t.done : !t.done);
});

const addTodoAtom = atom(
  null,
  (get, set, text: string) => {
    const todos = get(todosAtom);
    set(todosAtom, [...todos, { id: Date.now(), text, done: false }]);
  }
);

const toggleTodoAtom = atom(
  null,
  (get, set, id: number) => {
    const todos = get(todosAtom);
    set(todosAtom, todos.map(t => 
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }
);

function TodoApp() {
  const [text, setText] = useState('');
  const addTodo = useSetAtom(addTodoAtom);
  const todos = useAtomValue(filteredTodosAtom);
  const toggleTodo = useSetAtom(toggleTodoAtom);
  const [filter, setFilter] = useAtom(filterAtom);
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!text.trim()) return;
    addTodo(text);
    setText('');
  };
  
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={e => setText(e.target.value)} />
        <button type="submit">Add</button>
      </form>
      
      <div>
        <button onClick={() => setFilter('all')}>All</button>
        <button onClick={() => setFilter('active')}>Active</button>
        <button onClick={() => setFilter('done')}>Done</button>
      </div>
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
            {todo.done ? '✓' : '○'} {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

9. DevTools

npm install --save-dev jotai-devtools
import { DevTools } from 'jotai-devtools';

function App() {
  return (
    <>
      <DevTools />
      <YourApp />
    </>
  );
}

10. Best Practices

1. Use Atom Families for Dynamic Data

// Good: scales to any number of users
const userFamily = atomFamily((id) => atom(/* ... */));

// Bad: hardcoded atoms
const user1Atom = atom(/* ... */);
const user2Atom = atom(/* ... */);

2. Separate Read and Write

// Good: optimized re-renders
const count = useAtomValue(countAtom);
const setCount = useSetAtom(countAtom);

// Okay: when you need both
const [count, setCount] = useAtom(countAtom);

3. Use Write-only Atoms for Actions

const addTodoAtom = atom(null, (get, set, text) => {
  // Action logic
});

Summary

Jotai provides primitive, flexible React state:

  • Atomic state management
  • Minimal API - easy to learn
  • TypeScript first
  • Async support built-in
  • No Provider needed

Key Takeaways:

  1. Atoms are units of state
  2. Derived atoms for computed values
  3. Write-only atoms for actions
  4. Async atoms with Suspense
  5. Fine-grained re-renders

Next Steps:

  • Try Zustand
  • Learn Recoil
  • Compare Redux

Resources: