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:
- Atoms are units of state
- Derived atoms for computed values
- Write-only atoms for actions
- Async atoms with Suspense
- Fine-grained re-renders
Next Steps:
- Try Zustand
- Learn Recoil
- Compare Redux
Resources: