지난 Zustand편에 이어 Jotai를 알아보았다. Jotai의 핵심 개념과 철학, 그리고 실제 활용법까지 확인해보자.
일단 의미는 이제는 많은 사람들이 알고 있는데, 단순히 일본어로 "상태"라는 뜻이다. 기존의 top-down 방식 상태와는 완전히 다른 bottom-up atomic approach를 제안하는 React 상태 관리 라이브러리이다.
핵심 철학 : 상태를 원자 단위로 생각할 것!
<script>
const bigStore = {
user: { name: 'wtlee', email: 'wtlee@email.com' },
counter: { count: 0 },
todos: []
}
// Jotai의 방식 : 작은 원자들의 조합 -> 상태 구성
const userNameAtom = atom('wtlee');
const userEmailAtom = atom('wtlee@email.com');
const countAtom = atom(0);
const todosAtom = atom([]);
</script>
<script>
import { atom } from 'jotai'
const countAtom = atom(0);
const nameAtom = atom('wtlee');
const isLoadingAtom = atom(false);
// 객체 atom
const userAtom = atom({ name: 'wtlee', age: 35 });
// 배열 atom
const todosAtom = atom([
{ id: 1, text: '밥먹기', completed: false },
{ id: 2, text: '놀기', completed: true },
]);
</script>
<script>
// 1. Read-Write Atom (기본)
const countAtom = atom(0);
// 2. Read-Only Atom (Derived Atom)
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// 3. Write-Only Atom (Action Atom)
const incrementAtom = atom(
null,
(get, set) => {
set(countAtom, get(countAtom) + 1)
}
);
// 4. Read-Write Atom (Custom Logic)
const upperCaseNameAtom = atom(
(get) => get(nameAtom).toUpperCase(), // read
(get, set, newValue) => set(nameAtom, newValue.toLowerCase()); // write
</script>
<script>
// 전통적인 방식
class Store {
constructor() {
this.state = { count: 0, name: 'wtlee' };
this.subscribers = new Set();
}
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback); // closure
}
setState(newState) {
this.state = { ...this.state, ...newState };
this.subscribers.forEach(callback => callback(this.state));
}
}
---
// Jotai 방식 -> 자동 의존성 추적
const countAtom = atom(0);
const nameAtom = atom('wtlee');
// 이 atom은 countAtom에만 의존하므로, count가 변경될 때만 업데이트
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// 이 atom은 countAtom과 nameAtom 모두에 의존
const greetingAtom = atom((get) => `Hello ${get(nameAtom)}, count is ${get(countAtom)}`
</script>
<script>
// 의존성 그래프 예시
const firstNameAtom = atom('wootaik');
const lastNameAtom = atom('Lee');
const ageAtom = atom(35);
// 파생 atoms - 자동으로 의존성 추적
const fullNameAtom = atom((get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`)
const greetingAtom = atom((get) =>
`Hello, I'm ${get(fullNameAtom)} and I'm ${get(ageAtom)} years old`
);
// firstNameAtom 변경 시:
// firstNameAtom → fullNameAtom → greetingAtom 순서로 업데이트
// ageAtom은 영향받지 않음
</script>
<script>
class JotaiStore {
constructor() {
this.atomStateMap = new WeakMap() // atom -> value
this.atomDependentsMap = new WeakMap() // atom -> Set<dependent atoms>
this.atomDependenciesMap = new WeakMap() // atom -> Set<dependency atoms>
this.atomListenersMap = new WeakMap() // atom -> Set<Listeners>
this.currentReadingAtom = null // 현재 읽고 있는 atom 추적
}
readAtom(atom) {
// 의존성 추적을 위해 현재 읽고 있는 atom 기록
const prevReadingAtom = this.currentReadingAtom; // 의존성 추적을 위해 현재 읽고 있는 atom 기록
this.currentReadingAtom = atom
let value;
if (atom.read) {
// derived atom (파생된 atom)인 경우
value = atom.read(this.readAtom.bind(this));
} else {
// primitive atom인 경우
if (!this.atomStateMap.has(atom)) {
this.atomStateMap.set(atom, atom.init)
}
value = this.atomStateMap.get(atom)
}
// 의존성 등록
if (prevReadingAtom && prevReadingAtom !== atom) {
this.addDependency(prevReadingAtom, atom);
}
this.currentReadingAtom = prevReadingAtom;
return value;
}
writeAtom(atom, value) {
if (atom.write) {
atom.write(this.readAtom.bind(this), this.writeAtom.bind(this), value)
} else {
this.atomStateMap.set(atom, value);
}
// 의존하는 atoms들에게 알림
this.invalidateAtom(atom);
}
addDependency(dependent, dependency) {
if (!this.atomDependentsMap.has(dependency)) {
this.atomDependentsMap.set(dependency, new Set())
}
this.atomDependentsMap.get(dependency).add(dependent);
if (!this.atomDependenciseMap.has(dependent)) {
this.atomDependenciesMap.set(dependent, new Set());
}
this.atomDependenciesMap.get(dependent).add(dependency);
}
invalidateAtom(atom) {
// 이 atom에 의존하는 모든 atoms를 무효화
const dependents = this.atomDependentsMap.get(atom)
if (dependents) {
dependents.forEach(dependent => {
this.invalidateAtom(dependent)
})
}
// 리스너들에게 알림
const listeners = this.atomListenersMap.get(atom)
if (listeners) {
listeners.forEach(listener => listener())
}
}
}
</script>
<script>
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
// atoms 정의
const countAtom = atom(0)
const nameAtom = atom('wootaik')
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</div>
)
}
function NameDisplay() {
const name = useAtomValue(nameAtom) // 읽기 전용
return <h1>Hello, {name}!</h1>
}
function NameInput() {
const setName = useSetAtom(nameAtom) // 쓰기 전용
return (
<input
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
)
}
</script>
<script>
// 기본 atoms
const todosAtom = atom([])
const filterAtom = atom('all') // 'all' | 'completed' | 'active'
// 파생 atoms
const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom)
const filter = get(filterAtom)
switch (filter) {
case 'completed':
return todos.filter(todo => todo.completed)
case 'active':
return todos.filter(todo => !todo.completed)
default:
return todos
}
})
const todoStatsAtom = atom((get) => {
const todos = get(todosAtom)
return {
total: todos.length,
completed: todos.filter(todo => todo.completed).length,
active: todos.filter(todo => !todo.completed).length
}
})
// 액션 atoms
const addTodoAtom = atom(
null,
(get, set, text) => {
const todos = get(todosAtom)
set(todosAtom, [
...todos,
{ id: Date.now(), text, completed: false }
])
}
)
const toggleTodoAtom = atom(
null,
(get, set, id) => {
const todos = get(todosAtom)
set(todosAtom,
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
)
}
)
// 컴포넌트에서 사용
function TodoApp() {
return (
<div>
<AddTodo />
<FilterButtons />
<TodoList />
<TodoStats />
</div>
)
}
function AddTodo() {
const [, addTodo] = useAtom(addTodoAtom)
const [text, setText] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
if (text.trim()) {
addTodo(text.trim())
setText('')
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add todo..."
/>
<button type="submit">Add</button>
</form>
)
}
function TodoList() {
const filteredTodos = useAtomValue(filteredTodosAtom)
return (
<ul>
{filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)
}
function TodoItem({ todo }) {
const [, toggleTodo] = useAtom(toggleTodoAtom)
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
</li>
)
}
function TodoStats() {
const stats = useAtomValue(todoStatsAtom)
return (
<div>
Total: {stats.total} |
Completed: {stats.completed} |
Active: {stats.active}
</div>
)
}
</script>
<script>
import { atomFamily } from 'jotai/utils'
// ID별로 동적으로 atom 생성
const todoAtomFamily = atomFamily((id) =>
atom({ id, text: '', completed: false })
)
// 사용자별 설정 atoms
const userSettingsFamily = atomFamily((userId) =>
atom({ theme: 'light', language: 'en', notifications: true })
)
function TodoItem({ id }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id))
return (
<div>
<input
value={todo.text}
onChange={(e) => setTodo(prev => ({ ...prev, text: e.target.value }))}
/>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => setTodo(prev => ({ ...prev, completed: e.target.checked }))}
/>
</div>
)
}
</script>
<script>
// 비동기 데이터 fetching
const userAtom = atom(async (get) => {
const userId = get(userIdAtom)
const response = await fetch(`/api/users/${userId}`)
return response.json()
})
// Suspense와 함께 사용
function UserProfile() {
const user = useAtomValue(userAtom)
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
function App() {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
</ErrorBoundary>
)
}
</script>
<script>
import { atomWithStorage } from 'jotai/utils'
// localStorage와 동기화
const themeAtom = atomWithStorage('theme', 'light')
const userPreferencesAtom = atomWithStorage('userPrefs', {
language: 'en',
timezone: 'UTC'
})
function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom)
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
)
}
</script>
<script>
// localStorage 동기화 effect
const countWithEffectAtom = atom(
0,
(get, set, newValue) => {
set(countAtom, newValue)
localStorage.setItem('count', String(newValue))
}
)
// API 호출 effect
const saveUserAtom = atom(
null,
async (get, set, userData) => {
set(loadingAtom, true)
try {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData)
})
const user = await response.json()
set(userAtom, user)
set(successMessageAtom, 'User saved successfully!')
} catch (error) {
set(errorAtom, error.message)
} finally {
set(loadingAtom, false)
}
}
)
</script>
<script>
// 필요한 데이터만 정확히 구독
function UserName() {
const name = useAtomValue(userNameAtom) // name만 변경될 때 리렌더링
return <span>{name}</span>
}
function UserEmail() {
const email = useAtomValue(userEmailAtom) // email만 변경될 때 리렌더링
return <span>{email}</span>
}
</script>
<script>
// 복잡한 계산은 자동으로 메모이제이션됨
const expensiveComputationAtom = atom((get) => {
const data = get(dataAtom)
// 무거운 계산 - data가 변경될 때만 재계산
return data.reduce((acc, item) => {
// 복잡한 연산...
return acc + item.value * item.weight
}, 0)
})
</script>
<script>
import { atomWithReset, RESET } from 'jotai/utils'
const conditionalAtom = atom((get) => {
const isActive = get(isActiveAtom)
if (!isActive) {
return null // 비활성 상태에서는 계산하지 않음
}
return get(expensiveAtom)
})
</script>
의존성 추적이 자동화되어 불필요한 리렌더링 없음
복잡한 selector 최적화가 불필요
작은 atoms를 조합하여 복잡한 상태 구성
재사용 가능한 상태 로직
React의 useState와 유사한 사용법
학습 곡선이 낮음
뛰어난 타입 추론
타입 안정성 보장
Promise와 Suspense의 자연스러운 통합
로딩/에러 상태 자동 관리
개별 atom 단위 테스트
간단한 모킹