Jotai: Primitive and Flexible State Management for React
Complete guide to Jotai—atomic state management that's simpler than Redux and more powerful than useState
TypeScript Masterclass
AVAILABLE NOW
I’ve tried every React state management library. Redux, MobX, Recoil, Zustand. Each has strengths, but Jotai stands out for being simple yet powerful.
Jotai (Japanese for “state”) uses atoms—small, independent pieces of state. No boilerplate, no providers, no context. Just atoms and hooks. It feels like using useState
, but your state is global and composable.
I’ve been using Jotai in production for over a year. This guide covers everything from basics to advanced patterns.
What is Jotai?
Jotai is a primitive and flexible state management library for React. It’s built on atomic state, which means you work with small, independent units that can be combined together.
The bundle size is tiny at just 3kb. It has minimal boilerplate, no Context providers to wrap your app in, and it’s TypeScript-first. It works seamlessly with Suspense and Concurrent Mode. The philosophy is simple: start small with atoms, compose them when needed with derived atoms, and scale naturally.
Why Jotai?
If you’re coming from useState
, you know the pain of prop drilling. You have to pass state down through multiple components just to share it. With Jotai, your state is global. No prop drilling.
The Context API is supposed to solve this, but then you end up with providers everywhere and re-render issues. Jotai gives you the benefits without the downsides. No providers, and renders are optimized.
Compared to Redux, Jotai is refreshingly simple. No boilerplate, no actions, no reducers. Just atoms. If you’ve used Zustand, you’ll notice the difference—Zustand is store-based (top-down), while Jotai is atom-based (bottom-up). Both are great, just different approaches.
Recoil is similar to Jotai, but heavier with more features. Jotai is simpler, lighter, and has better TypeScript support in my experience.
I reach for Jotai when I want simple global state, need to avoid prop drilling, have derived state to compute, or when working on TypeScript projects where I value simplicity.
Installation
npm install jotai
# or
pnpm install jotai
# or
bun install jotai
No additional setup required. No providers. Just import and use.
Your first atom
Create an atom:
import { atom } from 'jotai';
const countAtom = atom(0);
Use in a component:
import { useAtom } from 'jotai';
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
That’s it. No providers, no context, no boilerplate.
Understanding atoms
Atoms are the building blocks:
// Primitive atom
const nameAtom = atom('John');
// Number atom
const ageAtom = atom(25);
// Boolean atom
const isLoadingAtom = atom(false);
// Object atom
const userAtom = atom({
name: 'John',
email: '[email protected]'
});
// Array atom
const todosAtom = atom([
{ id: 1, text: 'Learn Jotai', done: false }
]);
Atoms are just definitions. They don’t hold state until used in a component.
Reading and writing atoms
useAtom (read and write)
import { useAtom } from 'jotai';
function Component() {
const [value, setValue] = useAtom(myAtom);
return (
<button onClick={() => setValue(value + 1)}>
{value}
</button>
);
}
useAtomValue (read only)
import { useAtomValue } from 'jotai';
function Display() {
const value = useAtomValue(myAtom);
return <div>{value}</div>;
}
useSetAtom (write only)
import { useSetAtom } from 'jotai';
function Controls() {
const setValue = useSetAtom(myAtom);
return (
<button onClick={() => setValue(10)}>
Set to 10
</button>
);
}
Derived atoms
Atoms can derive from other atoms:
const firstNameAtom = atom('John');
const lastNameAtom = atom('Doe');
// Read-only derived atom
const fullNameAtom = atom((get) => {
const firstName = get(firstNameAtom);
const lastName = get(lastNameAtom);
return `${firstName} ${lastName}`;
});
// Usage
function FullName() {
const fullName = useAtomValue(fullNameAtom);
return <div>{fullName}</div>; // "John Doe"
}
Derived atoms automatically update when dependencies change.
Write-only atoms
Atoms can be write-only (for actions):
const countAtom = atom(0);
const incrementAtom = atom(
null, // Read returns null
(get, set) => {
const count = get(countAtom);
set(countAtom, count + 1);
}
);
// Usage
function Counter() {
const count = useAtomValue(countAtom);
const increment = useSetAtom(incrementAtom);
return (
<div>
<p>{count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
Read-write atoms
Atoms can have custom read and write logic:
const celsiusAtom = atom(0);
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9/5 + 32, // Read
(get, set, newFahrenheit) => { // Write
set(celsiusAtom, (newFahrenheit - 32) * 5/9);
}
);
// Usage
function Temperature() {
const [celsius, setCelsius] = useAtom(celsiusAtom);
const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom);
return (
<div>
<input
value={celsius}
onChange={(e) => setCelsius(Number(e.target.value))}
/>
<input
value={fahrenheit}
onChange={(e) => setFahrenheit(Number(e.target.value))}
/>
</div>
);
}
Both inputs stay in sync!
Async atoms
Atoms can be async:
const userIdAtom = atom(1);
const userAtom = atom(async (get) => {
const userId = get(userIdAtom);
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
// Usage with Suspense
function User() {
const user = useAtomValue(userAtom);
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<User />
</Suspense>
);
}
Jotai integrates seamlessly with React Suspense.
Atom families
Create atoms dynamically:
import { atomFamily } from 'jotai/utils';
const todoAtomFamily = atomFamily((id: number) =>
atom({
id,
text: '',
done: false
})
);
// Usage
function Todo({ id }: { id: number }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id));
return (
<div>
<input
value={todo.text}
onChange={(e) => setTodo({ ...todo, text: e.target.value })}
/>
<input
type="checkbox"
checked={todo.done}
onChange={(e) => setTodo({ ...todo, done: e.target.checked })}
/>
</div>
);
}
Organizing atoms
Keep atoms in separate files:
atoms/user.ts:
import { atom } from 'jotai';
export const userAtom = atom<User | null>(null);
export const isLoggedInAtom = atom((get) => {
const user = get(userAtom);
return user !== null;
});
export const userNameAtom = atom((get) => {
const user = get(userAtom);
return user?.name ?? 'Guest';
});
atoms/todos.ts:
import { atom } from 'jotai';
export const todosAtom = atom<Todo[]>([]);
export const todoCountAtom = atom((get) =>
get(todosAtom).length
);
export const completedTodosAtom = atom((get) =>
get(todosAtom).filter(todo => todo.done)
);
export const addTodoAtom = atom(
null,
(get, set, text: string) => {
const todos = get(todosAtom);
set(todosAtom, [
...todos,
{
id: Date.now(),
text,
done: false
}
]);
}
);
TypeScript support
Jotai has excellent TypeScript support:
type User = {
id: number;
name: string;
email: string;
};
// Typed atom
const userAtom = atom<User | null>(null);
// TypeScript infers the type
const nameAtom = atom((get) => {
const user = get(userAtom);
return user?.name ?? 'Guest';
// TypeScript knows: string
});
// Typed write function
const updateUserAtom = atom(
null,
(get, set, update: Partial<User>) => {
const user = get(userAtom);
if (user) {
set(userAtom, { ...user, ...update });
}
}
);
Persistence
Save atoms to localStorage:
import { atomWithStorage } from 'jotai/utils';
const darkModeAtom = atomWithStorage('darkMode', false);
// Usage
function DarkModeToggle() {
const [darkMode, setDarkMode] = useAtom(darkModeAtom);
return (
<button onClick={() => setDarkMode(!darkMode)}>
{darkMode ? 'Light' : 'Dark'} Mode
</button>
);
}
The value persists across page reloads.
Custom storage
Use any storage:
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
const storage = createJSONStorage<string>(() => sessionStorage);
const tokenAtom = atomWithStorage('token', '', storage);
Resetting atoms
Reset atoms to their initial value:
import { useResetAtom } from 'jotai/utils';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const resetCount = useResetAtom(countAtom);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={resetCount}>Reset</button>
</div>
);
}
Atom effects
Run side effects when atoms change:
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
const userAtom = atom<User | null>(null);
function useTrackUser() {
const user = useAtomValue(userAtom);
useEffect(() => {
if (user) {
analytics.identify(user.id);
}
}, [user]);
}
Or use atomEffect
:
import { atomEffect } from 'jotai-effect';
const userAtom = atom<User | null>(null);
const trackUserEffect = atomEffect((get, set) => {
const user = get(userAtom);
if (user) {
analytics.identify(user.id);
}
});
Optimistic updates
Update UI immediately, sync with server later:
const todosAtom = atom<Todo[]>([]);
const addTodoAtom = atom(
null,
async (get, set, text: string) => {
const newTodo = {
id: Date.now(),
text,
done: false,
pending: true
};
// Optimistic update
const todos = get(todosAtom);
set(todosAtom, [...todos, newTodo]);
try {
// Sync with server
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text })
});
const savedTodo = await response.json();
// Update with server response
set(todosAtom, (prev) =>
prev.map(todo =>
todo.id === newTodo.id
? { ...savedTodo, pending: false }
: todo
)
);
} catch (error) {
// Rollback on error
set(todosAtom, (prev) =>
prev.filter(todo => todo.id !== newTodo.id)
);
}
}
);
Debugging
Use Redux DevTools with Jotai:
import { useAtomDevtools } from 'jotai-devtools';
function MyComponent() {
const [count, setCount] = useAtom(countAtom);
// Enable DevTools for this atom
useAtomDevtools(countAtom, { name: 'count' });
return <div>{count}</div>;
}
Or use the DevTools UI:
import { DevTools } from 'jotai-devtools';
function App() {
return (
<>
<DevTools />
<YourApp />
</>
);
}
Real-world example: Todo app
Complete todo app with Jotai:
atoms/todos.ts:
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export type Todo = {
id: number;
text: string;
done: boolean;
};
// Persisted todos
export const todosAtom = atomWithStorage<Todo[]>('todos', []);
// Filter
export const filterAtom = atom<'all' | 'active' | 'completed'>('all');
// Filtered todos
export const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
if (filter === 'active') {
return todos.filter(todo => !todo.done);
}
if (filter === 'completed') {
return todos.filter(todo => todo.done);
}
return todos;
});
// Stats
export const statsAtom = atom((get) => {
const todos = get(todosAtom);
return {
total: todos.length,
active: todos.filter(t => !t.done).length,
completed: todos.filter(t => t.done).length
};
});
// Actions
export const addTodoAtom = atom(
null,
(get, set, text: string) => {
const todos = get(todosAtom);
set(todosAtom, [
...todos,
{
id: Date.now(),
text,
done: false
}
]);
}
);
export const toggleTodoAtom = atom(
null,
(get, set, id: number) => {
const todos = get(todosAtom);
set(todosAtom, todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}
);
export const deleteTodoAtom = atom(
null,
(get, set, id: number) => {
const todos = get(todosAtom);
set(todosAtom, todos.filter(todo => todo.id !== id));
}
);
export const clearCompletedAtom = atom(
null,
(get, set) => {
const todos = get(todosAtom);
set(todosAtom, todos.filter(todo => !todo.done));
}
);
components/TodoList.tsx:
import { useAtomValue } from 'jotai';
import { filteredTodosAtom } from '../atoms/todos';
import TodoItem from './TodoItem';
export default function TodoList() {
const todos = useAtomValue(filteredTodosAtom);
if (todos.length === 0) {
return <p>No todos yet!</p>;
}
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} id={todo.id} />
))}
</ul>
);
}
components/TodoItem.tsx:
import { useAtomValue, useSetAtom } from 'jotai';
import { todosAtom, toggleTodoAtom, deleteTodoAtom } from '../atoms/todos';
export default function TodoItem({ id }: { id: number }) {
const todos = useAtomValue(todosAtom);
const todo = todos.find(t => t.id === id)!;
const toggle = useSetAtom(toggleTodoAtom);
const deleteTodo = useSetAtom(deleteTodoAtom);
return (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggle(id)}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(id)}>Delete</button>
</li>
);
}
components/AddTodo.tsx:
import { useSetAtom } from 'jotai';
import { useState } from 'react';
import { addTodoAtom } from '../atoms/todos';
export default function AddTodo() {
const [text, setText] = useState('');
const addTodo = useSetAtom(addTodoAtom);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
addTodo(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
);
}
components/TodoFilters.tsx:
import { useAtom } from 'jotai';
import { filterAtom } from '../atoms/todos';
export default function TodoFilters() {
const [filter, setFilter] = useAtom(filterAtom);
return (
<div>
<button
onClick={() => setFilter('all')}
disabled={filter === 'all'}
>
All
</button>
<button
onClick={() => setFilter('active')}
disabled={filter === 'active'}
>
Active
</button>
<button
onClick={() => setFilter('completed')}
disabled={filter === 'completed'}
>
Completed
</button>
</div>
);
}
components/TodoStats.tsx:
import { useAtomValue, useSetAtom } from 'jotai';
import { statsAtom, clearCompletedAtom } from '../atoms/todos';
export default function TodoStats() {
const stats = useAtomValue(statsAtom);
const clearCompleted = useSetAtom(clearCompletedAtom);
return (
<div>
<span>{stats.active} items left</span>
{stats.completed > 0 && (
<button onClick={clearCompleted}>
Clear completed ({stats.completed})
</button>
)}
</div>
);
}
App.tsx:
import AddTodo from './components/AddTodo';
import TodoList from './components/TodoList';
import TodoFilters from './components/TodoFilters';
import TodoStats from './components/TodoStats';
export default function App() {
return (
<div>
<h1>Todos</h1>
<AddTodo />
<TodoFilters />
<TodoList />
<TodoStats />
</div>
);
}
Advanced patterns
Computed atoms with dependencies
const itemsAtom = atom([1, 2, 3, 4, 5]);
const filterAtom = atom('all');
const filteredItemsAtom = atom((get) => {
const items = get(itemsAtom);
const filter = get(filterAtom);
if (filter === 'even') {
return items.filter(n => n % 2 === 0);
}
if (filter === 'odd') {
return items.filter(n => n % 2 !== 0);
}
return items;
});
Atom with validation
const emailAtom = atom('');
const validEmailAtom = atom(
(get) => get(emailAtom),
(get, set, newEmail: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(newEmail)) {
set(emailAtom, newEmail);
}
}
);
Atom with history
import { atomWithReducer } from 'jotai/utils';
type History<T> = {
past: T[];
present: T;
future: T[];
};
function historyReducer<T>(
state: History<T>,
action: { type: 'set' | 'undo' | 'redo'; value?: T }
) {
switch (action.type) {
case 'set':
return {
past: [...state.past, state.present],
present: action.value as T,
future: []
};
case 'undo':
if (state.past.length === 0) return state;
const previous = state.past[state.past.length - 1];
return {
past: state.past.slice(0, -1),
present: previous,
future: [state.present, ...state.future]
};
case 'redo':
if (state.future.length === 0) return state;
const next = state.future[0];
return {
past: [...state.past, state.present],
present: next,
future: state.future.slice(1)
};
default:
return state;
}
}
const countHistoryAtom = atomWithReducer<History<number>, any>(
{ past: [], present: 0, future: [] },
historyReducer
);
Lazy atoms
import { atom } from 'jotai';
const lazyAtom = atom(async () => {
// Only fetches when first accessed
const response = await fetch('/api/config');
return response.json();
});
Integration with React Query
Combine Jotai with React Query:
import { atomWithQuery } from 'jotai-tanstack-query';
const userIdAtom = atom(1);
const userQueryAtom = atomWithQuery((get) => ({
queryKey: ['user', get(userIdAtom)],
queryFn: async ({ queryKey: [, id] }) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
}));
// Usage
function User() {
const { data, isLoading } = useAtomValue(userQueryAtom);
if (isLoading) return <div>Loading...</div>;
return <div>{data.name}</div>;
}
Testing
Test atoms in isolation:
import { createStore } from 'jotai';
describe('countAtom', () => {
it('should increment', () => {
const store = createStore();
// Get initial value
expect(store.get(countAtom)).toBe(0);
// Set new value
store.set(countAtom, 5);
expect(store.get(countAtom)).toBe(5);
});
});
Test derived atoms:
describe('fullNameAtom', () => {
it('should combine first and last name', () => {
const store = createStore();
store.set(firstNameAtom, 'John');
store.set(lastNameAtom, 'Doe');
expect(store.get(fullNameAtom)).toBe('John Doe');
});
});
Best practices
Keep your atoms small and focused. One piece of state per atom. This makes them reusable and easy to reason about.
Use derived atoms for computed values instead of duplicating data. If you can derive it, derive it. Don’t store it twice.
Organize your atoms by feature. I keep related atoms together in the same file, like all user-related atoms in atoms/user.ts
and all todo atoms in atoms/todos.ts
.
Jotai’s TypeScript support is excellent, so use it. You’ll catch bugs early and get great autocomplete.
Write-only atoms are great for actions. They help separate concerns between state and logic. Your state atoms just hold data, your action atoms modify it.
Atom families are powerful for dynamic lists. Instead of managing a single big array, you can have individual atoms for each item.
Use atomWithStorage
when you need persistence. It’s built-in localStorage integration that just works.
Keep your state flat when possible. Avoid deep nesting in atoms. It makes updates easier and reduces bugs.
For async atoms, embrace Suspense. It’s the React way of handling async state, and Jotai integrates beautifully.
Test your atoms separately from components. They’re just functions, so testing is straightforward.
Common patterns
Loading states
const dataAtom = atom(async () => {
const response = await fetch('/api/data');
return response.json();
});
const loadingAtom = atom(false);
Error handling
const dataAtom = atom<Data | null>(null);
const errorAtom = atom<Error | null>(null);
const fetchDataAtom = atom(
null,
async (get, set) => {
set(errorAtom, null);
try {
const response = await fetch('/api/data');
const data = await response.json();
set(dataAtom, data);
} catch (error) {
set(errorAtom, error as Error);
}
}
);
Form state
const formAtom = atom({
name: '',
email: '',
message: ''
});
const formErrorsAtom = atom((get) => {
const form = get(formAtom);
const errors: Partial<typeof form> = {};
if (!form.name) errors.name = 'Name is required';
if (!form.email.includes('@')) errors.email = 'Invalid email';
if (form.message.length < 10) errors.message = 'Message too short';
return errors;
});
const isValidAtom = atom((get) => {
const errors = get(formErrorsAtom);
return Object.keys(errors).length === 0;
});
Migration from other libraries
From useState
// Before
const [count, setCount] = useState(0);
// After
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);
From Context
// Before
const ThemeContext = createContext('light');
const theme = useContext(ThemeContext);
// After
const themeAtom = atom('light');
const theme = useAtomValue(themeAtom);
From Redux
// Before Redux
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
default:
return state;
}
}
// After Jotai
const countAtom = atom(0);
const incrementAtom = atom(null, (get, set) => {
set(countAtom, get(countAtom) + 1);
});
When to use Jotai
Jotai is perfect for apps that need simple global state, especially TypeScript projects. If you’re tired of prop drilling or need derived/computed state, it’s a great fit. I use it for small to medium apps all the time.
For very simple apps where useState
is enough, Jotai might be overkill. And if you’re on a large team that’s unfamiliar with atomic state, there might be a learning curve. If you absolutely need Redux DevTools deeply integrated into your workflow, you might want to stick with Redux.
Resources
The docs are at jotai.org, and they’re really good. Clear examples and explanations.
You can find more examples at github.com/pmndrs/jotai/tree/main/examples to see different patterns and use cases.
The community is active on Discord and GitHub discussions if you get stuck.
Conclusion
Jotai is my favorite state management library for React. It’s simple, powerful, and feels natural. No boilerplate, no providers, just atoms and hooks.
I use it for side projects, client apps, MVPs, basically any TypeScript React app I build. The atomic approach scales naturally. You start with simple atoms, compose them as needed, and derive state when it’s useful. There’s no upfront complexity to deal with.
Try Jotai on your next project. Install it, create an atom, use it, and build from there. You’ll appreciate how simple it makes state management.
npm install jotai
The simplicity is what keeps me coming back to it.
I wrote 20 books to help you become a better developer:
- JavaScript Handbook
- TypeScript Handbook
- CSS Handbook
- Node.js Handbook
- Astro Handbook
- HTML Handbook
- Next.js Pages Router Handbook
- Alpine.js Handbook
- HTMX Handbook
- React Handbook
- SQL Handbook
- Git Cheat Sheet
- Laravel Handbook
- Express Handbook
- Swift Handbook
- Go Handbook
- PHP Handbook
- Python Handbook
- Linux/Mac CLI Commands Handbook
- C Handbook