# Jotai Jotai is a primitive and flexible state management library for React that takes an atomic approach to global state management. It scales from a simple `useState` replacement to enterprise applications with complex requirements. With a minimal core API of just 2kb, Jotai provides an intuitive way to manage state using atoms - small units of state that can be combined and composed to build complex state logic. The library is TypeScript-oriented and works seamlessly with Next.js, Remix, Waku, and React Native. Unlike other state management solutions, Jotai doesn't use string keys (like Recoil) and leverages React Suspense for handling asynchronous operations natively. Atoms are created outside of components, can be composed into derived atoms, and automatically track dependencies for optimal re-renders. ## Core API ### atom - Create State Atoms The `atom` function creates an atom config that represents a piece of state. Atoms can be primitive (holding a simple value), derived read-only (computed from other atoms), derived write-only (action atoms), or read-write derived atoms. ```tsx import { atom } from 'jotai' // Primitive atoms - hold simple values const countAtom = atom(0) const nameAtom = atom('John') const todosAtom = atom([{ id: 1, text: 'Learn Jotai', done: false }]) // Read-only derived atom - computed from other atoms const doubledCountAtom = atom((get) => get(countAtom) * 2) // Write-only atom - action to modify state const incrementAtom = atom(null, (get, set) => { set(countAtom, get(countAtom) + 1) }) // Read-write derived atom - both computed value and setter const countWithActionsAtom = atom( (get) => get(countAtom), (get, set, action: 'increment' | 'decrement') => { const current = get(countAtom) set(countAtom, action === 'increment' ? current + 1 : current - 1) } ) // Async derived atom - fetches data reactively const userIdAtom = atom(1) const userAtom = atom(async (get, { signal }) => { const userId = get(userIdAtom) const response = await fetch( `https://jsonplaceholder.typicode.com/users/${userId}`, { signal } ) return response.json() }) // Using onMount for side effects when atom is first subscribed const wsAtom = atom(null) wsAtom.onMount = (setAtom) => { const ws = new WebSocket('wss://example.com') ws.onmessage = (e) => setAtom(JSON.parse(e.data)) return () => ws.close() // cleanup on unmount } ``` ### useAtom - Read and Write Atoms The `useAtom` hook reads an atom's value and returns an update function, similar to React's `useState`. It automatically subscribes to atom changes and triggers re-renders when the atom value updates. ```tsx import { atom, useAtom } from 'jotai' const countAtom = atom(0) function Counter() { const [count, setCount] = useAtom(countAtom) return (

Count: {count}

) } // Creating atoms dynamically (must be memoized) function DynamicAtomExample({ initialValue }: { initialValue: number }) { const [value, setValue] = useAtom( useMemo(() => atom(initialValue), [initialValue]) ) return {value} } ``` ### useAtomValue - Read-Only Access The `useAtomValue` hook provides read-only access to an atom's value. It's useful when you only need to read state without updating it, and provides better performance than destructuring `useAtom`. ```tsx import { atom, useAtomValue } from 'jotai' const countAtom = atom(0) const doubledAtom = atom((get) => get(countAtom) * 2) function Display() { const count = useAtomValue(countAtom) const doubled = useAtomValue(doubledAtom) return (

Count: {count}

Doubled: {doubled}

) } ``` ### useSetAtom - Write-Only Access The `useSetAtom` hook returns only the setter function for an atom. This is useful when you need to update state without subscribing to changes, preventing unnecessary re-renders. ```tsx import { atom, useSetAtom, useAtomValue } from 'jotai' const countAtom = atom(0) // This component won't re-render when countAtom changes function IncrementButton() { const setCount = useSetAtom(countAtom) return } // This component only displays and re-renders on changes function CountDisplay() { const count = useAtomValue(countAtom) return {count} } function App() { return ( <> ) } ``` ### Provider - Scoped State Container The `Provider` component creates an isolated state container for a component subtree. Multiple providers can coexist, and nested providers create independent state scopes. Without a Provider, atoms use a default global store. ```tsx import { atom, useAtom, Provider, createStore } from 'jotai' const countAtom = atom(0) function Counter() { const [count, setCount] = useAtom(countAtom) return } // Each Provider has independent state function App() { return (
{/* This counter is independent */} {/* This counter is also independent */}
) } // Using a custom store const myStore = createStore() myStore.set(countAtom, 10) // Pre-set value function AppWithStore() { return ( {/* Starts at 10 */} ) } ``` ### createStore - External Store Management The `createStore` function creates a store that can be used both inside and outside React components. It provides `get`, `set`, and `sub` methods for reading, writing, and subscribing to atom changes. ```tsx import { atom, createStore, Provider, useAtomValue } from 'jotai' const countAtom = atom(0) const store = createStore() // Use store outside React store.set(countAtom, 10) console.log(store.get(countAtom)) // 10 // Subscribe to changes const unsub = store.sub(countAtom, () => { console.log('Count changed:', store.get(countAtom)) }) // Update from anywhere (e.g., WebSocket handler, timer) setInterval(() => { store.set(countAtom, (prev) => prev + 1) }, 1000) // Use same store in React function App() { return ( ) } function CountDisplay() { const count = useAtomValue(countAtom) return
{count}
} ``` ## Utilities (jotai/utils) ### atomWithStorage - Persistent State The `atomWithStorage` utility creates an atom that automatically persists to localStorage, sessionStorage, or AsyncStorage (React Native). It includes cross-tab synchronization for web applications. ```tsx import { useAtom } from 'jotai' import { atomWithStorage, createJSONStorage, RESET } from 'jotai/utils' import AsyncStorage from '@react-native-async-storage/async-storage' // Basic localStorage persistence const darkModeAtom = atomWithStorage('darkMode', false) // With sessionStorage const sessionStorage = createJSONStorage(() => globalThis.sessionStorage) const sessionAtom = atomWithStorage('session', null, sessionStorage) // React Native with AsyncStorage const rnStorage = createJSONStorage(() => AsyncStorage) const userPrefsAtom = atomWithStorage('userPrefs', {}, rnStorage) // With getOnInit for immediate stored value const themeAtom = atomWithStorage('theme', 'light', undefined, { getOnInit: true, // Returns stored value immediately, not initialValue }) function Settings() { const [darkMode, setDarkMode] = useAtom(darkModeAtom) return (
{/* Reset to initial value and remove from storage */}
) } ``` ### atomWithReset - Resettable Atoms The `atomWithReset` utility creates atoms that can be reset to their initial value using the `RESET` symbol or `useResetAtom` hook. ```tsx import { useAtom } from 'jotai' import { atomWithReset, useResetAtom, RESET } from 'jotai/utils' const counterAtom = atomWithReset(0) const formAtom = atomWithReset({ name: '', email: '' }) function Counter() { const [count, setCount] = useAtom(counterAtom) const resetCount = useResetAtom(counterAtom) return (
{count} {/* Alternative: setCount(RESET) */}
) } function Form() { const [form, setForm] = useAtom(formAtom) return (
setForm((f) => ({ ...f, name: e.target.value }))} />
) } ``` ### atomWithDefault - Dynamic Default Values The `atomWithDefault` utility creates a resettable atom whose default value is computed from other atoms. The computed default is used until the atom is explicitly set. ```tsx import { atom, useAtom } from 'jotai' import { atomWithDefault, useResetAtom, RESET } from 'jotai/utils' const baseCountAtom = atom(1) const derivedCountAtom = atomWithDefault((get) => get(baseCountAtom) * 2) function Example() { const [baseCount, setBaseCount] = useAtom(baseCountAtom) const [derivedCount, setDerivedCount] = useAtom(derivedCountAtom) const resetDerived = useResetAtom(derivedCountAtom) return (

Base: {baseCount}

Derived: {derivedCount}

) } ``` ### atomWithRefresh - Refreshable Async Atoms The `atomWithRefresh` utility creates an atom that can be manually refreshed, forcing the read function to re-evaluate. This is useful for implementing "pull to refresh" functionality. ```tsx import { useAtom } from 'jotai' import { atomWithRefresh } from 'jotai/utils' const postsAtom = atomWithRefresh(async (get) => { const response = await fetch('https://jsonplaceholder.typicode.com/posts') return response.json() }) function PostsList() { const [posts, refreshPosts] = useAtom(postsAtom) return (
    {posts.map((post: { id: number; title: string }) => (
  • {post.title}
  • ))}
) } ``` ### loadable - Non-Suspending Async The `loadable` utility wraps an async atom to provide loading/error states without using React Suspense. It returns an object with `state`, `data`, and `error` properties. ```tsx import { atom, useAtom } from 'jotai' import { loadable } from 'jotai/utils' const asyncAtom = atom(async () => { const response = await fetch('https://api.example.com/data') if (!response.ok) throw new Error('Failed to fetch') return response.json() }) const loadableAtom = loadable(asyncAtom) function DataDisplay() { const [state] = useAtom(loadableAtom) if (state.state === 'loading') { return
Loading...
} if (state.state === 'hasError') { return
Error: {state.error.message}
} // state.state === 'hasData' return
Data: {JSON.stringify(state.data)}
} // No Suspense boundary needed! function App() { return } ``` ### unwrap - Simplified Async Unwrapping The `unwrap` utility converts an async atom to a sync atom with a fallback value during loading. Unlike `loadable`, errors are thrown rather than captured. ```tsx import { atom, useAtomValue } from 'jotai' import { unwrap } from 'jotai/utils' const countAtom = atom(0) const delayedCountAtom = atom(async (get) => { await new Promise((r) => setTimeout(r, 1000)) return get(countAtom) }) // Returns undefined while loading const simpleUnwrappedAtom = unwrap(delayedCountAtom) // Returns 0 initially, then keeps previous value while refreshing const unwrappedWithFallbackAtom = unwrap( delayedCountAtom, (prev) => prev ?? 0 ) function Display() { const value = useAtomValue(unwrappedWithFallbackAtom) return
Value: {value}
} ``` ### atomFamily - Parameterized Atoms The `atomFamily` utility creates a function that returns atoms based on parameters, with caching to ensure the same parameters return the same atom instance. ```tsx import { atom, useAtom } from 'jotai' import { atomFamily } from 'jotai/utils' // Simple parameterized atom const todoAtomFamily = atomFamily((id: number) => atom({ id, text: '', completed: false }) ) // With derived state const userAtomFamily = atomFamily((userId: string) => atom(async () => { const response = await fetch(`/api/users/${userId}`) return response.json() }) ) // With custom equality (for object params) import deepEqual from 'fast-deep-equal' const queryAtomFamily = atomFamily( (params: { page: number; filter: string }) => atom(params), deepEqual ) function TodoItem({ id }: { id: number }) { const [todo, setTodo] = useAtom(todoAtomFamily(id)) return (
setTodo({ ...todo, text: e.target.value })} /> setTodo({ ...todo, completed: !todo.completed })} />
) } // Memory management - remove unused atoms todoAtomFamily.remove(5) // Remove atom for id=5 // Auto-cleanup old atoms todoAtomFamily.setShouldRemove((createdAt, param) => { return Date.now() - createdAt > 60 * 60 * 1000 // Remove after 1 hour }) ``` ### atomWithLazy - Deferred Initialization The `atomWithLazy` utility creates a primitive atom whose initial value is computed lazily on first use, useful for expensive initialization that should be deferred. ```tsx import { useAtom } from 'jotai' import { atomWithLazy } from 'jotai/utils' // Expensive initialization deferred until first use const imageDataAtom = atomWithLazy(() => { console.log('Initializing expensive image data...') return generateExpensiveImageData() }) function ImageEditor() { // Initialization happens here, not at import time const [imageData, setImageData] = useAtom(imageDataAtom) return } function Home() { // imageDataAtom is not initialized if user never visits ImageEditor return
Welcome!
} ``` ### useHydrateAtoms - SSR Hydration The `useHydrateAtoms` hook sets initial values for atoms during hydration in SSR applications. Values are only set once per store to prevent overwriting client-side state. ```tsx import { atom, useAtom, Provider } from 'jotai' import { useHydrateAtoms } from 'jotai/utils' const countAtom = atom(0) const userAtom = atom(null) // Custom provider for testing/SSR function HydrateAtoms({ initialValues, children, }: { initialValues: Iterable, unknown]> children: React.ReactNode }) { useHydrateAtoms(initialValues) return children } // Usage in Next.js page function Page({ serverData }: { serverData: { count: number; user: User } }) { return ( ) } // For testing with injected values function TestProvider({ initialValues, children, }: { initialValues: Map, unknown> children: React.ReactNode }) { return ( {children} ) } ``` ### useAtomCallback - Imperative Atom Access The `useAtomCallback` hook provides imperative access to atoms for use cases like form submission, event handlers, or integration with non-React code. ```tsx import { atom, useAtom } from 'jotai' import { useAtomCallback } from 'jotai/utils' import { useCallback } from 'react' const countAtom = atom(0) const userAtom = atom({ name: 'John', email: 'john@example.com' }) function FormWithSubmit() { const [user, setUser] = useAtom(userAtom) const submitForm = useAtomCallback( useCallback(async (get, set) => { const currentUser = get(userAtom) const count = get(countAtom) const response = await fetch('/api/submit', { method: 'POST', body: JSON.stringify({ user: currentUser, count }), }) if (response.ok) { set(countAtom, 0) // Reset count after submission } return response.json() }, []) ) return (
{ e.preventDefault(); submitForm(); }}> setUser({ ...user, name: e.target.value })} />
) } ``` ### splitAtom - Array Item Atoms The `splitAtom` utility transforms an atom containing an array into an atom containing an array of atoms, one for each item. This enables efficient individual item updates. ```tsx import { atom, useAtom, PrimitiveAtom } from 'jotai' import { splitAtom } from 'jotai/utils' interface Todo { id: number text: string done: boolean } const todosAtom = atom([ { id: 1, text: 'Learn Jotai', done: false }, { id: 2, text: 'Build app', done: false }, ]) const todoAtomsAtom = splitAtom(todosAtom) function TodoItem({ todoAtom }: { todoAtom: PrimitiveAtom }) { const [todo, setTodo] = useAtom(todoAtom) return (
setTodo({ ...todo, text: e.target.value })} /> setTodo({ ...todo, done: !todo.done })} />
) } function TodoList() { const [todoAtoms, dispatch] = useAtom(todoAtomsAtom) return (
{todoAtoms.map((todoAtom) => (
))}
) } ``` ### atomWithReducer - Reducer Pattern The `atomWithReducer` utility creates an atom that uses a reducer function for state updates, similar to React's `useReducer`. ```tsx import { useAtom } from 'jotai' import { atomWithReducer } from 'jotai/utils' type CounterAction = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' } const counterReducer = (state: number, action: CounterAction): number => { switch (action.type) { case 'increment': return state + 1 case 'decrement': return state - 1 case 'reset': return 0 default: return state } } const counterAtom = atomWithReducer(0, counterReducer) function Counter() { const [count, dispatch] = useAtom(counterAtom) return (
{count}
) } ``` ### atomWithObservable - RxJS Integration The `atomWithObservable` utility creates an atom from an RxJS Observable or Subject, enabling reactive streams integration with Jotai state. ```tsx import { useAtom } from 'jotai' import { atomWithObservable } from 'jotai/utils' import { interval, BehaviorSubject } from 'rxjs' import { map } from 'rxjs/operators' // From interval observable const timerAtom = atomWithObservable(() => interval(1000).pipe(map((i) => `Tick: ${i}`)) ) // From BehaviorSubject (read/write) const subject = new BehaviorSubject(0) const subjectAtom = atomWithObservable(() => subject) // With initial value (avoids suspense) const timerWithInitialAtom = atomWithObservable( () => interval(1000).pipe(map((i) => i)), { initialValue: 0 } ) function Timer() { const [time] = useAtom(timerWithInitialAtom) return
Time: {time}
} ``` ### selectAtom - Derived Slice with Equality The `selectAtom` utility creates a derived atom that only updates when a selected slice of the source atom changes, using custom equality comparison. ```tsx import { atom, useAtom } from 'jotai' import { selectAtom } from 'jotai/utils' import { useMemo, useCallback } from 'react' import deepEqual from 'fast-deep-equal' const userAtom = atom({ name: { first: 'John', last: 'Doe' }, address: { city: 'NYC', zip: '10001' }, preferences: { theme: 'dark' }, }) // Select only the name - updates when name object changes const nameAtom = selectAtom(userAtom, (user) => user.name) // With deep equality - only updates when actual values change const addressAtom = selectAtom( userAtom, (user) => user.address, deepEqual ) function NameDisplay() { // Must use stable selector reference const nameAtom = useMemo( () => selectAtom(userAtom, (user) => user.name), [] ) const [name] = useAtom(nameAtom) return
{name.first} {name.last}
} ``` ## Async Patterns ### Suspense with Async Atoms Jotai leverages React Suspense for async atoms. When an async atom's promise is pending, components using it will suspend until the promise resolves. ```tsx import { Suspense } from 'react' import { atom, useAtom } from 'jotai' const userIdAtom = atom(1) const userAtom = atom(async (get, { signal }) => { const id = get(userIdAtom) const res = await fetch(`https://api.example.com/users/${id}`, { signal }) return res.json() }) // Dependent async atom const userPostsAtom = atom(async (get) => { const user = await get(userAtom) // Must await in dependent atoms const res = await fetch(`https://api.example.com/users/${user.id}/posts`) return res.json() }) function UserProfile() { const [user] = useAtom(userAtom) return
{user.name}
} function App() { return ( Loading user...}> Loading posts...}> ) } ``` ### Async Write Actions Write functions can be async for operations like API calls. The returned promise can be awaited by the caller. ```tsx import { atom, useAtom } from 'jotai' const todosAtom = atom([]) const addTodoAtom = atom(null, async (get, set, text: string) => { const response = await fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }), }) const newTodo = await response.json() set(todosAtom, [...get(todosAtom), newTodo]) return newTodo }) function AddTodo() { const [, addTodo] = useAtom(addTodoAtom) const [text, setText] = useState('') const [isLoading, setIsLoading] = useState(false) const handleSubmit = async () => { setIsLoading(true) try { await addTodo(text) setText('') } finally { setIsLoading(false) } } return (
setText(e.target.value)} />
) } ``` ## Testing ### Testing Components with Atoms Test components using atoms by treating Jotai as an implementation detail. Use Provider with `useHydrateAtoms` to inject test values. ```tsx import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Provider, atom, useAtom } from 'jotai' import { useHydrateAtoms } from 'jotai/utils' const countAtom = atom(0) function Counter() { const [count, setCount] = useAtom(countAtom) return (
{count}
) } // Test helper function TestProvider({ initialValues, children, }: { initialValues: [Atom, unknown][] children: React.ReactNode }) { return ( {children} ) } function HydrateAtoms({ initialValues, children }) { useHydrateAtoms(initialValues) return children } // Tests test('counter increments', async () => { render() expect(screen.getByTestId('count')).toHaveTextContent('0') await userEvent.click(screen.getByText('Increment')) expect(screen.getByTestId('count')).toHaveTextContent('1') }) test('counter with initial value', async () => { render( ) expect(screen.getByTestId('count')).toHaveTextContent('100') }) ``` ## Summary Jotai provides a powerful yet minimal approach to React state management through its atomic model. The core API consists of just four main exports (`atom`, `useAtom`, `Provider`, `createStore`) that handle most use cases, while the utilities package (`jotai/utils`) offers specialized atoms for persistence, async handling, collections, and SSR. The composable nature of atoms allows building complex state from simple primitives while maintaining fine-grained reactivity and optimal re-rendering. Common integration patterns include using `atomWithStorage` for persistent user preferences, `atomFamily` for dynamic collections, `loadable` or Suspense for async data fetching, and `createStore` for accessing state outside React components. For SSR frameworks like Next.js, `useHydrateAtoms` enables server-side data to initialize client-side atoms. Testing is straightforward by treating atoms as implementation details and using Provider with hydration for injecting test values.