Skip to content
React rc state-management 5 min read

Jotai & Atomic State

Jotai is a tiny, primitive-first state library that models global state as a collection of small, composable units called atoms. Instead of a single centralized store you wire up with reducers and providers, you declare independent pieces of state and let Jotai track which components depend on which atoms. The result is a bottom-up mental model that feels as natural as useState, scales to derived and async state, and only re-renders the components that actually read the atom that changed.

The atomic model

A traditional store (Redux, Zustand) is top-down: one big state object that components select slices from. Jotai is bottom-up: you compose application state out of many minimal atoms. An atom is just a configuration object — a unique reference that describes a piece of state. It holds no value by itself; the value lives in a store and is read through hooks.

Because Jotai tracks dependencies at the atom level, a component subscribed to countAtom never re-renders when an unrelated themeAtom changes. This dependency graph is what lets Jotai avoid both the manual selector tuning of Redux and the re-render fan-out of a naive Context value.

Installation

npm install jotai

That single package contains the core (atom, useAtom) plus utilities. No provider, no middleware, and no store boilerplate are required to get started.

Creating and using an atom

atom(initialValue) creates a primitive read/write atom. useAtom(atom) returns a [value, setValue] tuple with the exact same ergonomics as useState, so the migration cost from local state is essentially zero.

// atoms.js
import { atom } from 'jotai';

export const countAtom = atom(0);
// Counter.jsx
import { useAtom } from 'jotai';
import { countAtom } from './atoms';

export default function Counter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <section>
      <h2>Count: {count}</h2>
      <button onClick={() => setCount((c) => c - 1)}>-1</button>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </section>
  );
}

Any component anywhere in the tree that reads countAtom shares the same value. There is no <Provider> wrapping required — Jotai uses a built-in default store. Add a <Provider> only when you need isolated stores (for example, per-route state or testing).

No provider boilerplate

Compare the setup cost. With Redux you create a store, configure middleware, and wrap the app in <Provider store={store}>. With Context you build a provider component, memoize the value, and thread it down. With Jotai, you export an atom and import it — that is the entire global wiring.

If two components read the same atom and one updates it, both stay in sync automatically. You never pass setters through props or context.

Derived atoms

The real power of atomic state is composition. Pass a read function to atom to derive a value from other atoms. The derived atom re-computes only when one of its dependencies changes, and components reading it re-render only when its result changes.

// atoms.js
import { atom } from 'jotai';

export const priceAtom = atom(20);
export const quantityAtom = atom(2);

// read-only derived atom
export const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom));
// Cart.jsx
import { useAtom, useAtomValue } from 'jotai';
import { priceAtom, quantityAtom, totalAtom } from './atoms';

export default function Cart() {
  const [qty, setQty] = useAtom(quantityAtom);
  const total = useAtomValue(totalAtom); // read-only, no setter

  return (
    <section>
      <button onClick={() => setQty((q) => q + 1)}>Add one</button>
      <p>Quantity: {qty} · Total: ${total}</p>
    </section>
  );
}

useAtomValue reads without a setter; useSetAtom returns only the setter (handy for write-only buttons that should not re-render on value changes).

You can also create a writable derived atom by supplying both a read and a write function:

import { atom } from 'jotai';
import { priceAtom } from './atoms';

export const discountedAtom = atom(
  (get) => get(priceAtom) * 0.9,            // read
  (get, set, newPrice) => set(priceAtom, newPrice) // write
);

Async atoms

A read function may return a promise. Jotai integrates with Suspense, so the reading component suspends until the data resolves — no loading flags to thread manually.

import { atom, useAtomValue } from 'jotai';

const userIdAtom = atom(1);

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

function Profile() {
  const user = useAtomValue(userAtom); // suspends until resolved
  return <h2>{user.name}</h2>;
}

Wrap <Profile /> in <Suspense fallback={<p>Loading…</p>}>. Changing userIdAtom automatically refetches.

Output:

Loading…
Leanne Graham

Jotai vs Recoil

Jotai was inspired by Recoil but is leaner and avoids some of Recoil’s friction. Both use an atomic graph, yet they differ in API and footprint.

AspectJotaiRecoil
Atom keysNo string keys — atoms are object referencesRequires a unique key string per atom/selector
ProviderOptional (default store built in)<RecoilRoot> is mandatory
Derived stateatom((get) => ...)Separate selector({ key, get }) API
Bundle size~3 KBNoticeably larger
MaintenanceActively maintainedEffectively unmaintained
TypeScriptFirst-class, minimal genericsHeavier typing ceremony

For new projects the practical recommendation is Jotai: smaller, no key collisions, and a unified atom API for both primitive and derived state.

Best Practices

  • Keep atoms small and single-purpose; compose larger views with derived atoms rather than one fat atom.
  • Use useAtomValue for read-only consumers and useSetAtom for write-only ones to minimize unnecessary re-renders.
  • Define atoms in dedicated module(s) and import them — never create atoms inside render, or you will get a new atom every render.
  • Reach for derived atoms instead of useMemo when the derivation depends on shared state, so caching is global and automatic.
  • Use a scoped <Provider> only for isolation (tests, per-subtree state); rely on the default store otherwise.
  • Pair async atoms with <Suspense> and error boundaries instead of manual loading/error flags.
  • Prefer atomWithStorage (from jotai/utils) for persisted state rather than wiring localStorage by hand.
Last updated June 14, 2026
Was this helpful?