← Back to DevBytes

Jotai from Beginner to Expert: A Learning Path

Introduction to Jotai

Jotai (Japanese for "state") is a primitive and flexible state management library for React. It takes an atomic approach: state is split into small, independent pieces called atoms, and components can read or write them directly. Unlike Redux or Zustand, there’s no central store, no reducers, and no boilerplate. You define atoms, use them in components, and compose them to derive new state. The result is a minimal API surface, excellent TypeScript support, and seamless React Concurrent Mode compatibility.

Why Jotai Matters

Getting Started: The Basics

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Install Jotai from your package manager:

npm install jotai
# or
yarn add jotai

Your First Atom

An atom represents a piece of state. Create one with atom(initialValue). To read and write it inside a component, use the useAtom hook, which returns a tuple just like useState.

import { atom, useAtom } from 'jotai';

const counterAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(counterAtom);
  return (
    

Count: {count}

); }

Derived Atoms

Atoms can depend on other atoms. A derived atom is defined by passing a read function (and optionally a write function) to atom(). The read function receives get, which can read other atoms, and the write function receives get and set.

// Read-only: doubles the counter
const doubleCountAtom = atom((get) => get(counterAtom) * 2);

// Component using read-only atom
function DoubleDisplay() {
  const [doubleCount] = useAtom(doubleCountAtom); // only reads
  return 

Double: {doubleCount}

; } // Write-only: increments the counter by a specific amount const incrementByAtom = atom(null, (get, set, amount: number) => { set(counterAtom, get(counterAtom) + amount); }); function IncrementButton({ amount }: { amount: number }) { const [, increment] = useAtom(incrementByAtom); return ; } // Read-write: a controlled input that syncs with a local atom but also updates a parent atom const textAtom = atom(''); const uppercaseAtom = atom( (get) => get(textAtom).toUpperCase(), (get, set, newValue: string) => set(textAtom, newValue.toLowerCase()) );

Understanding Atoms in Depth

Atom Configuration

The atom function can accept:

Inside the write function, you have access to set, which can update any atom. The third argument is the value passed when calling the setter. You can also get to read the current value of any atom, including the atom being written, to support updater patterns.

const countAtom = atom(1);

const incrementAtom = atom(null, (get, set) => {
  set(countAtom, (prev) => prev + 1);
});

Async Atoms and Suspense

Derived atoms can return promises. When a component reads an async atom, React's Suspense will catch the promise and show a fallback until it resolves. This eliminates manual loading state management.

const userIdAtom = atom(1);

const userAtom = atom(async (get) => {
  const id = get(userIdAtom);
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  return response.json();
});

function UserProfile() {
  const [user] = useAtom(userAtom);
  return 
{user.name} — {user.email}
; } // Wrap with Suspense function App() { return ( Loading user...
}> ); }

For error handling, wrap with an ErrorBoundary (using libraries like react-error-boundary). Jotai also provides a useAtomCallback or loadable utilities for more granular control, but Suspense is the idiomatic way.

Atom Types and Serializability

Atoms can hold any value: primitives, objects, arrays, Maps, Sets, functions, or promises. However, for debugging and time-travel features, keeping atoms serializable is recommended. Jotai does not enforce immutability by default—you can mutate objects if you wish, but to trigger re-renders you must call set with a new reference. For complex structures, consider using splitAtom or libraries like Immer.

Advanced Patterns: Atom Families and Utilities

Atom Families

When you need multiple instances of the same atom pattern (e.g., a counter per item), use atomFamily. It takes a factory function and returns a function that creates or retrieves an atom for a given key.

import { atomFamily } from 'jotai/utils';

const countFamily = atomFamily((id: string) => atom(0));

function ItemCounter({ id }: { id: string }) {
  const [count, setCount] = useAtom(countFamily(id));
  return (
    
Item {id}: {count}
); }

The atom is created lazily on first access and cached. You can also provide a custom equality function to control when the atom is recreated. The atomFamily supports a second parameter: areEqual or shouldRemove.

Optimized Hooks: useAtomValue and useSetAtom

To prevent unnecessary re-renders when a component only needs to read or write an atom, use useAtomValue and useSetAtom. These hooks don't subscribe to the whole atom tuple, reducing rendering overhead.

import { useAtomValue, useSetAtom } from 'jotai';

const todosAtom = atom([]);

// Component that only displays count, not the array
function TodoCount() {
  const count = useAtomValue(todosAtom).length; // re-renders only when length changes? Actually useAtomValue subscribes to atom.
  // Better: use a derived atom for count
  return {count} items;
}

// Component that only adds a todo
function AddTodo() {
  const setTodos = useSetAtom(todosAtom);
  const add = (text: string) => {
    setTodos((prev) => [...prev, text]);
  };
  // ...
}

Note: useAtomValue still re-renders when the atom value changes. For truly granular subscriptions, split the atom or use selectAtom.

selectAtom and splitAtom

selectAtom lets you derive a piece of a larger atom and only re-render when that piece changes.

import { selectAtom } from 'jotai/utils';

const bigObjectAtom = atom({ name: 'Alice', age: 30, city: 'NYC' });

const nameAtom = selectAtom(bigObjectAtom, (obj) => obj.name);
// This component re-renders only when name changes
function NameDisplay() {
  const name = useAtomValue(nameAtom);
  return 

{name}

; }

splitAtom takes an array atom and returns a dynamic list of atoms for each element, perfect for editable lists.

import { splitAtom } from 'jotai/utils';

const shoppingListAtom = atom([
  { text: 'Milk', done: false },
  { text: 'Bread', done: false },
]);

const listAtomsAtom = splitAtom(shoppingListAtom);

function ShoppingList() {
  const [listAtoms] = useAtom(listAtomsAtom);
  return (
    
  );
}

function Item({ itemAtom }: { itemAtom: PrimitiveAtom<{ text: string; done: boolean }> }) {
  const [item, setItem] = useAtom(itemAtom);
  const toggle = () => setItem({ ...item, done: !item.done });
  return (
    
      {item.text}
    
  );
}

waitForAll and Async Utilities

waitForAll creates an atom that waits for multiple async atoms to resolve, returning an array of results. Perfect for parallel data fetching.

import { waitForAll } from 'jotai/utils';

const userAtom = atom(async () => fetch('/api/user').then(r => r.json()));
const postsAtom = atom(async () => fetch('/api/posts').then(r => r.json()));

const combinedAtom = waitForAll([userAtom, postsAtom]);

function Dashboard() {
  const [user, posts] = useAtomValue(combinedAtom);
  return (
    

{user.name}

{posts.map(p =>
{p.title}
)}
); }

useHydrateAtoms for SSR

When server-rendering, you may want to seed atoms with initial data. useHydrateAtoms allows you to hydrate atoms from outside (e.g., from props or a global store).

import { useHydrateAtoms } from 'jotai/utils';

const pageDataAtom = atom({});

function Page({ initialData }) {
  useHydrateAtoms([[pageDataAtom, initialData]]);
  const [data] = useAtom(pageDataAtom);
  // ...
}

Integration with React Ecosystem

Provider and Scoped Atoms

By default, Jotai uses a global store. For testing or isolating state to a component subtree, you can use Provider to create a scoped store. All atoms used inside that Provider will be independent from the global store.

import { Provider, atom, useAtom } from 'jotai';

const themeAtom = atom('light');

function ThemedApp() {
  return (
    
      
    
  );
}

This is especially useful in component libraries where you want to avoid leaking state.

Jotai with Next.js and React Native

Jotai works out of the box with Next.js App Router and Pages Router. For server components, you can create atoms that fetch data on the server, then pass initial values to client components via useHydrateAtoms. In React Native, Jotai has zero dependencies on DOM APIs and works seamlessly.

DevTools and Debugging

Jotai provides an official devtools package (jotai-devtools) that visualizes the atom dependency graph, allows time-travel debugging, and inspects atom values in real time. Install and wrap your app with the DevTools provider.

import { DevTools } from 'jotai-devtools';

function App() {
  return (
    
      {/* your app */}
    
  );
}

Best Practices and Patterns

Keep Atoms Small and Focused

Avoid large atoms that hold the entire application state. Instead, decompose state into primitive atoms and compose them with derived atoms. This minimizes re-renders and improves code clarity.

Prefer Derived Atoms Over Effect Hooks

Instead of using useEffect to synchronize state, derive values declaratively with atom. This avoids glitches and stale closures.

Avoid Unnecessary Re-renders

Handling Side Effects

Atoms themselves don't support side effects directly, but you can create a write-only atom that triggers an effect externally. For complex workflows, combine Jotai with useEffect or libraries like jotai-effect.

const logAtom = atom(null, (get, set, message: string) => {
  console.log('Log:', message);
});

// In a component
const [, log] = useAtom(logAtom);
log('User clicked button');

Colocate Atoms with Components

Jotai encourages colocation: define atoms in the same file or folder as the component that uses them. This improves modularity and avoids large, central state files. For shared atoms, create a small atoms.ts in the feature folder.

Testing Atoms

Atoms are pure functions; test them outside React by calling get and set directly on a store. Use createStore from Jotai to get a standalone store instance.

import { createStore, atom } from 'jotai';

const store = createStore();
const countAtom = atom(0);

store.set(countAtom, 5);
expect(store.get(countAtom)).toBe(5);

For components, wrap them with Provider using a custom store to isolate tests.

Migration from Redux or Recoil

Map Redux slices to atoms: each slice becomes a primitive atom with write functions. Use derived atoms for selectors. For Recoil, atoms and selectors map almost 1:1, but Jotai's API is simpler and doesn't require key strings. Migrate incrementally: you can run Jotai alongside Redux using a Provider.

Conclusion

Jotai takes you from simple counters to complex, async, and highly dynamic state architectures without ever forcing a rigid structure. Its atomic model encourages you to think in small, composable pieces, resulting in cleaner, more performant React applications. By mastering the basics, derived atoms, async patterns, and utilities like atomFamily and selectAtom, you can handle any state management challenge while keeping your codebase maintainable and testable. Start small, compose boldly, and let Jotai do the heavy lifting.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles