Skip to content

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.


→ Get my React Beginner's Handbook

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
...download them all now!

Related posts about react: