Jotai Basics: Primitive State Management for React 19
Jotai represents a fundamentally different approach to state management. Instead of stores containing multiple pieces of state, Jotai uses atoms — the smallest possible units of state. This primitive-first design makes it exceptionally elegant for building reactive systems where you can compose complex state from simple building blocks.
If you think of Zustand as "lightweight Redux," think of Jotai as "primitive state atoms." It's a different philosophy that many developers find more intuitive and flexible.
Table of Contents
- Why Jotai? The Atom Philosophy
- Core Concepts
- Creating Atoms
- Using Atoms in Components
- Derived Atoms
- Async Atoms
- Atom Dependencies
- Advanced Patterns
- Best Practices
- Jotai vs Zustand
- Real-World Examples
- FAQ
Why Jotai? The Atom Philosophy {#why-jotai}
The Problem with Store-Based Approaches
Traditional stores (Redux, Zustand) combine multiple pieces of state:
// Store: One big object with multiple pieces of state
const useStore = create(() => ({
user: null,
loading: false,
error: null,
posts: [],
theme: 'light',
notifications: [],
// ... all state together
}));
// Problem: Every change to any piece triggers re-renders
// You must manually select subsets to avoid re-renders
The Atom-Based Approach
Jotai separates state into independent atoms:
// Atoms: Each piece of state is separate and independent
const userAtom = atom<User | null>(null);
const loadingAtom = atom(false);
const errorAtom = atom<Error | null>(null);
const postsAtom = atom<Post[]>([]);
const themeAtom = atom('light');
const notificationsAtom = atom<Notification[]>([]);
// Benefit: Components subscribe only to atoms they need
// Changing one atom doesn't affect others
// Perfect composition and fine-grained reactivity
Why This Matters
| Aspect | Store-Based | Atom-Based |
|---|---|---|
| Bundle Size | Zustand 2KB | Jotai 3KB |
| Mental Model | One big object | Many small atoms |
| Composition | Manual selection | Natural dependencies |
| Re-render Control | Automatic | Automatic |
| Learning Curve | Very easy | Easy-Medium |
| Best For | Simple to medium | Medium to complex |
Core Concepts {#concepts}
What is an Atom?
An atom is the smallest possible unit of state. Think of it as a container for a single value that can be read and written.
import { atom } from 'jotai';
// ✅ Primitive atoms: Contain a single value
const countAtom = atom(0);
const nameAtom = atom('John');
const isLoadingAtom = atom(false);
const userAtom = atom<User | null>(null);
Atoms vs Hooks
Atoms are not hooks. They're separate objects that can be used by multiple components:
// ❌ WRONG: Confusing atom with a hook
const useCount = atom(0); // This is wrong
const count = useCount(); // This won't work
// ✅ CORRECT: Atoms are objects
const countAtom = atom(0);
// ✅ CORRECT: Use the atom in components with a hook
const count = useAtom(countAtom);
The Three Atom States
Every atom has three operations:
- Read — Get the current value
- Write — Update the value
- Subscribe — React to changes
Creating Atoms {#creating-atoms}
Primitive Atoms
The simplest atoms hold primitive values:
import { atom } from 'jotai';
// Primitive values
export const countAtom = atom(0);
export const nameAtom = atom('');
export const isVisibleAtom = atom(false);
// Objects and arrays
interface User {
id: string;
name: string;
email: string;
}
export const userAtom = atom<User | null>(null);
export const todosAtom = atom<Todo[]>([]);
// Union types
type Status = 'idle' | 'loading' | 'success' | 'error';
export const statusAtom = atom<Status>('idle');
Atoms with Initialization Functions
For complex initial values:
// Initialize with a function
const dateAtom = atom(() => new Date());
// Initialize array
const itemsAtom = atom(() => [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
]);
// Initialize with side effects
const initialDataAtom = atom(() => {
const stored = localStorage.getItem('data');
return stored ? JSON.parse(stored) : null;
});
Best Practice: Organize Atoms
Keep atoms in separate files by feature:
// atoms/user.ts
import { atom } from 'jotai';
interface User {
id: string;
name: string;
email: string;
}
export const userAtom = atom<User | null>(null);
export const userLoadingAtom = atom(false);
export const userErrorAtom = atom<Error | null>(null);
// atoms/theme.ts
export const themeAtom = atom<'light' | 'dark'>('light');
export const fontSizeAtom = atom(14);
// atoms/index.ts
export * from './user';
export * from './theme';
Using Atoms in Components {#using-atoms}
Basic Usage with useAtom
import { useAtom } from 'jotai';
import { countAtom } from './atoms';
export function Counter() {
// ✅ useAtom provides both read and write
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
Read-Only: useAtomValue
When you only need to read:
import { useAtomValue } from 'jotai';
export function CountDisplay() {
// ✅ Only subscribes to reads, not writes
const count = useAtomValue(countAtom);
return <p>Count: {count}</p>;
}
Write-Only: useSetAtom
When you only need to write:
import { useSetAtom } from 'jotai';
export function CountControls() {
// ✅ Only get the setter function
const setCount = useSetAtom(countAtom);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>+</button>
<button onClick={() => setCount(0)}>Reset</button>
</>
);
}
Update Functions
Like useState, you can use update functions:
// ✅ Update function receives previous value
setCount((prevCount) => prevCount + 1);
// ✅ Works with objects too
setUser((prevUser) => ({
...prevUser,
name: 'New Name',
}));
// ✅ Update function in component
export function TodoAdder() {
const setTodos = useSetAtom(todosAtom);
const addTodo = (text: string) => {
setTodos((todos) => [
...todos,
{ id: Date.now(), text, completed: false },
]);
};
return (
// JSX
);
}
Multiple Atoms
Use multiple atoms independently:
interface User {
id: string;
name: string;
}
interface Post {
id: string;
title: string;
userId: string;
}
export function UserProfile() {
const [user, setUser] = useAtom(userAtom);
const [posts, setPosts] = useAtom(postsAtom);
const [theme] = useAtom(themeAtom);
return (
<div className={`profile ${theme}`}>
<h1>{user?.name}</h1>
<p>Posts: {posts.length}</p>
</div>
);
}
Derived Atoms {#derived-atoms}
What are Derived Atoms?
Derived atoms compute their value from other atoms. They're read-only and update automatically:
import { atom } from 'jotai';
const countAtom = atom(0);
// ✅ Derived atom: reads from countAtom
const doubleAtom = atom((get) => {
const count = get(countAtom);
return count * 2;
});
// Derived atom automatically updates when countAtom changes
export function Display() {
const double = useAtomValue(doubleAtom); // Always 2x the count
}
Common Derived Atom Patterns
interface Todo {
id: string;
text: string;
completed: boolean;
}
const todosAtom = atom<Todo[]>([]);
// ✅ Filter derived atom
const completedTodosAtom = atom((get) => {
const todos = get(todosAtom);
return todos.filter((t) => t.completed);
});
// ✅ Count derived atom
const todoCountAtom = atom((get) => {
const todos = get(todosAtom);
return todos.length;
});
// ✅ Completion percentage
const completionPercentageAtom = atom((get) => {
const todos = get(todosAtom);
if (todos.length === 0) return 0;
const completed = todos.filter((t) => t.completed).length;
return Math.round((completed / todos.length) * 100);
});
// ✅ Multiple atom dependencies
const summaryAtom = atom((get) => {
const todos = get(todosAtom);
const count = get(todoCountAtom);
const completed = get(completedTodosAtom).length;
return {
total: count,
completed,
pending: count - completed,
completionRate: count === 0 ? 0 : (completed / count) * 100,
};
});
Chaining Derived Atoms
Derived atoms can read from other derived atoms:
// Level 1: Read from primitive atom
const countAtom = atom(5);
// Level 2: Derives from level 1
const doubleAtom = atom((get) => get(countAtom) * 2);
// Level 3: Derives from level 2
const quadrupleAtom = atom((get) => get(doubleAtom) * 2);
// Result: quadrupleAtom = 20 when countAtom = 5
export function Display() {
const quad = useAtomValue(quadrupleAtom); // 20
}
Async Atoms {#async-atoms}
Basic Async Atoms
Async atoms return promises:
import { atom } from 'jotai';
interface Post {
id: number;
title: string;
body: string;
}
// ✅ Async atom: Returns a promise
const postsAtom = atom(async () => {
const response = await fetch('https://api.example.com/posts');
return response.json() as Promise<Post[]>;
});
export function PostList() {
// Handle loading and error automatically
const [posts, reFetch] = useAtom(postsAtom);
if (posts instanceof Promise) {
return <div>Loading...</div>;
}
return (
<div>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
<button onClick={() => reFetch()}>Refresh</button>
</div>
);
}
Suspense Integration
Use Suspense for async atoms:
import { Suspense } from 'react';
import { useAtomValue } from 'jotai';
export function PostListWithSuspense() {
return (
<Suspense fallback={<div>Loading posts...</div>}>
<PostContent />
</Suspense>
);
}
function PostContent() {
// ✅ Throws promise during loading, caught by Suspense
const posts = useAtomValue(postsAtom);
return (
<div>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
Async Atoms with Dependencies
const userIdAtom = atom(1);
// ✅ Async atom depends on another atom
const userAtom = atom(async (get) => {
const userId = get(userIdAtom);
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
export function UserProfile() {
const user = useAtomValue(userAtom);
const setUserId = useSetAtom(userIdAtom);
if (user instanceof Promise) {
return <div>Loading user...</div>;
}
return (
<div>
<h1>{user.name}</h1>
<button onClick={() => setUserId(2)}>Load User 2</button>
</div>
);
}
Refetch Pattern
// ✅ Create a separate atom for refetch trigger
const refetchKeyAtom = atom(0);
const dataAtom = atom(async (get) => {
get(refetchKeyAtom); // Depend on refetch key
const response = await fetch('/api/data');
return response.json();
});
export function DataComponent() {
const data = useAtomValue(dataAtom);
const setRefetchKey = useSetAtom(refetchKeyAtom);
const refetch = () => {
setRefetchKey((k) => k + 1); // Triggers re-fetch
};
return (
<div>
<div>{JSON.stringify(data)}</div>
<button onClick={refetch}>Refetch</button>
</div>
);
}
Atom Dependencies {#dependencies}
Reading Multiple Atoms
Derived atoms can read from multiple atoms:
const firstNameAtom = atom('John');
const lastNameAtom = atom('Doe');
// ✅ Read from multiple atoms
const fullNameAtom = atom((get) => {
const first = get(firstNameAtom);
const last = get(lastNameAtom);
return `${first} ${last}`;
});
Conditional Dependencies
const userIdAtom = atom<number | null>(null);
// ✅ Conditionally read other atoms
const userAtom = atom(async (get) => {
const userId = get(userIdAtom);
if (userId === null) {
return null; // No user selected
}
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
Writing to Multiple Atoms
const firstNameAtom = atom('');
const lastNameAtom = atom('');
// ✅ Atom that writes to multiple atoms
const setFullNameAtom = atom(
null,
(_get, set, fullName: string) => {
const [first, last] = fullName.split(' ');
set(firstNameAtom, first);
set(lastNameAtom, last);
}
);
export function NameEditor() {
const setFullName = useSetAtom(setFullNameAtom);
return (
<input
onChange={(e) => setFullName(e.target.value)}
placeholder="Enter full name"
/>
);
}
Advanced Patterns {#advanced-patterns}
Write-Only Atoms
Atoms that write to other atoms:
const countAtom = atom(0);
// ✅ Write-only atom
const incrementAtom = atom(null, (_get, set) => {
set(countAtom, (c) => c + 1);
});
export function Counter() {
const increment = useSetAtom(incrementAtom);
const count = useAtomValue(countAtom);
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
</div>
);
}
Atom with Both Read and Write
const baseAtom = atom(0);
// ✅ Custom read and write logic
const customAtom = atom(
// Read: returns value * 2
(get) => get(baseAtom) * 2,
// Write: sets base to value / 2
(_get, set, newValue: number) => {
set(baseAtom, newValue / 2);
}
);
export function Component() {
const [value, setValue] = useAtom(customAtom);
// value is always double the baseAtom
// Setting value updates baseAtom accordingly
}
Atom Effects (Side Effects)
import { atom } from 'jotai';
import { atomEffect } from 'jotai-effect';
const countAtom = atom(0);
// ✅ Atom with side effect
const countWithSideEffectAtom = atomEffect(
(get, set) => {
const count = get(countAtom);
// Side effect: log to console
console.log('Count changed to:', count);
// Can also set other atoms
if (count > 10) {
set(warningAtom, true);
}
},
[countAtom]
);
Best Practices {#best-practices}
1. Keep Atoms Focused
// ✅ GOOD: Each atom has one responsibility
const userAtom = atom<User | null>(null);
const userLoadingAtom = atom(false);
const userErrorAtom = atom<Error | null>(null);
// ❌ AVOID: Mixing concerns
const userStateAtom = atom({
data: null,
loading: false,
error: null,
});
2. Use Derived Atoms for Computed Values
// ❌ WRONG: Storing derived values
const itemsAtom = atom<Item[]>([]);
const countAtom = atom(0); // Manually set and kept in sync
// ✅ CORRECT: Derive the count
const itemsAtom = atom<Item[]>([]);
const countAtom = atom((get) => get(itemsAtom).length);
3. Organize Atoms by Feature
src/
atoms/
user.ts // User-related atoms
posts.ts // Post-related atoms
ui.ts // UI state atoms
index.ts // Export all atoms
components/
hooks/
4. Use useAtomValue for Read-Only Components
// ✅ GOOD: Only read, no re-render on write
const count = useAtomValue(countAtom);
// ⚠️ LESS EFFICIENT: Subscribes to both read and write
const [count] = useAtom(countAtom);
5. Type Your Atoms
// ✅ GOOD: Explicit types
const userAtom = atom<User | null>(null);
const postsAtom = atom<Post[]>([]);
// ❌ AVOID: Implicit types
const userAtom = atom(null); // Type is 'null'
Jotai vs Zustand {#vs-zustand}
| Aspect | Jotai | Zustand |
|---|---|---|
| Philosophy | Atoms (primitives) | Store (single object) |
| Bundle Size | 3KB | 2KB |
| Learning Curve | Medium | Very Easy |
| Composition | Natural | Manual selection |
| DevTools | Limited | Excellent |
| Async Support | Built-in | Simple callbacks |
| Derived State | Native | Manual selectors |
| Best For | Complex state trees | Simple to medium |
| Middleware | Via atoms | Built-in |
When to Use Jotai
✅ Complex state with many dependencies
✅ Need fine-grained reactivity
✅ Prefer functional composition
✅ Atomic state updates
When to Use Zustand
✅ Simple applications
✅ Prefer store-based model
✅ Need excellent DevTools
✅ Team familiar with Redux
Real-World Examples {#examples}
Example 1: Todo Application
import { atom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
interface Todo {
id: string;
text: string;
completed: boolean;
}
// Primitive atoms
export const todosAtom = atom<Todo[]>([]);
export const filterAtom = atom<'all' | 'active' | 'completed'>('all');
// Derived atoms
export const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
switch (filter) {
case 'active':
return todos.filter((t) => !t.completed);
case 'completed':
return todos.filter((t) => t.completed);
default:
return todos;
}
});
export const statsAtom = atom((get) => {
const todos = get(todosAtom);
return {
total: todos.length,
completed: todos.filter((t) => t.completed).length,
active: todos.filter((t) => !t.completed).length,
};
});
// Write atoms
export const addTodoAtom = atom(null, (_get, set, text: string) => {
set(todosAtom, (todos) => [
...todos,
{
id: Date.now().toString(),
text,
completed: false,
},
]);
});
export const toggleTodoAtom = atom(
null,
(_get, set, id: string) => {
set(todosAtom, (todos) =>
todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
)
);
}
);
// Usage
export function TodoApp() {
const [todos, setTodos] = useAtom(filteredTodosAtom);
const [filter, setFilter] = useAtom(filterAtom);
const stats = useAtomValue(statsAtom);
const addTodo = useSetAtom(addTodoAtom);
const toggleTodo = useSetAtom(toggleTodoAtom);
return (
<div>
<h1>Todos ({stats.total})</h1>
<input
onKeyDown={(e) => {
if (e.key === 'Enter') {
addTodo((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).value = '';
}
}}
placeholder="Add a todo"
/>
<div>
<button onClick={() => setFilter('all')}>All ({stats.total})</button>
<button onClick={() => setFilter('active')}>
Active ({stats.active})
</button>
<button onClick={() => setFilter('completed')}>
Completed ({stats.completed})
</button>
</div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : '' }}>
{todo.text}
</span>
</li>
))}
</ul>
</div>
);
}
Example 2: Form State Management
import { atom } from 'jotai';
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
// Primitive atoms for each field
export const emailAtom = atom('');
export const passwordAtom = atom('');
export const rememberMeAtom = atom(false);
// Combined form data
export const formDataAtom = atom((get) => ({
email: get(emailAtom),
password: get(passwordAtom),
rememberMe: get(rememberMeAtom),
}));
// Validation atoms
export const isValidEmailAtom = atom((get) => {
const email = get(emailAtom);
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
});
export const isPasswordStrongAtom = atom((get) => {
const password = get(passwordAtom);
return password.length >= 8;
});
export const isFormValidAtom = atom((get) => {
return get(isValidEmailAtom) && get(isPasswordStrongAtom);
});
// Submit handler
export const submitFormAtom = atom(
null,
async (_get, _set, formData: FormData) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(formData),
});
return response.json();
}
);
// Usage
export function LoginForm() {
const [email, setEmail] = useAtom(emailAtom);
const [password, setPassword] = useAtom(passwordAtom);
const [rememberMe, setRememberMe] = useAtom(rememberMeAtom);
const isFormValid = useAtomValue(isFormValidAtom);
const isValidEmail = useAtomValue(isValidEmailAtom);
const isPasswordStrong = useAtomValue(isPasswordStrongAtom);
const submit = useSetAtom(submitFormAtom);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
await submit({ email, password, rememberMe });
}}
>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
{!isValidEmail && <span className="error">Invalid email</span>}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{!isPasswordStrong && (
<span className="error">Password must be 8+ characters</span>
)}
<label>
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
Remember me
</label>
<button disabled={!isFormValid}>Login</button>
</form>
);
}
FAQ {#faq}
Q: What's the difference between an atom and a hook?
A: Atoms are values/objects. Hooks (useAtom, useAtomValue) are how you use atoms in components. An atom can be used by multiple components, while hooks are component-specific.
Q: Do I need a provider like Redux?
A: No. Jotai works without any provider setup, though you can wrap your app in <Provider> for better performance control.
Q: Can atoms replace useState?
A: Yes, for shared state. For local component state, useState is fine. Use atoms when multiple components need the same state.
Q: How do I handle async operations?
A: Create async atoms that return promises. Combine with Suspense or check if value is a Promise.
Q: What's the performance impact?
A: Jotai is highly optimized. Only components using atoms re-render when those atoms change. No unnecessary re-renders.
Q: Can I use Jotai with TypeScript?
A: Full TypeScript support. Atoms have proper generic types for type safety.
Q: How do I debug Jotai atoms?
A: Use browser console logging, or install Jotai DevTools browser extension. Atoms are accessible via useAtomValue directly.
Q: Should I split atoms or combine them?
A: Split atoms by logical concern. A form might have email, password, and submit status as separate atoms, but user profile data might be one atom.
Master Jotai's atomic approach: Once you understand atoms, you'll appreciate their elegance and composability. Start simple with primitive atoms and gradually use derived atoms for complex state logic.
Next Steps: Explore Zustand Guide for comparison, learn custom hooks patterns for component logic, and master async patterns for data fetching.
Google AdSense Placeholder
CONTENT SLOT