EmpireUI
Get Pro
← Blog9 min read#jotai#atoms#react

Jotai Guide 2026: Atoms, Derived State, Async Atoms, Molecule Pattern

A practical deep-dive into Jotai — atoms, derived state, async atoms, and the molecule pattern — for React devs who want minimal boilerplate.

code editor open on a React state management file with colorful syntax highlighting

Why Jotai Instead of Zustand or Redux

State management in React has always been a bit of a bloodsport. Redux made you write reducers, actions, selectors, and middleware just to store a boolean. Zustand fixed a lot of that — but you're still defining a store as a single object, and splitting that object into clean independent units takes discipline. Jotai takes a completely different swing at the problem.

The core idea: every piece of state is an *atom*, a standalone reactive unit. You compose state from atoms instead of slicing a monolithic store. That might sound like a tiny stylistic difference, but it changes how you actually think about your app's data. Honestly, once you spend a week with Jotai, going back to a single-store model feels like carrying a suitcase when you only needed a backpack.

As of Jotai v2 (released in 2023 and still the stable line through 2026), the API is rock-solid. There's no Provider required for most use cases, atoms are garbage-collected when no component consumes them, and the bundle is under 3 KB minzipped. That last bit matters if you're shipping a design-system-heavy app on top of something like Empire UI — every KB counts.

That said, Jotai isn't a silver bullet. If you need time-travel debugging, Redux DevTools integration is still better with Zustand. But for UI state — open/closed modals, selected tabs, async data fetching — Jotai is hard to beat. Check the zustand-vs-jotai-2026 deep-dive if you're still on the fence.

Atoms: The Foundation

An atom is just a config object. You call atom(initialValue) and you get back something you can read and write with the useAtom hook. That's the whole model for primitive state.

import { atom, useAtom } from 'jotai'

// Define outside the component — atoms are singletons
const countAtom = atom(0)
const isOpenAtom = atom(false)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  )
}

Worth noting: you define atoms *outside* your component tree. They're not hooks, they're not context — they're module-level config objects. This means two completely separate component trees can share the same atom without any Provider wiring. That's a big deal for micro-frontends or complex portal-based UIs.

You can also use useAtomValue if you only need to read (no setter), and useSetAtom if you only need to write. Splitting them up reduces the number of re-renders on components that only trigger state changes without caring about the current value. Quick aside: in a 2024 profiling session on a form-heavy app, swapping from useAtom to useSetAtom on submit buttons dropped their re-render count to zero. Free performance.

Atoms accept any value — primitives, objects, arrays, Maps, Sets. There's no requirement to keep things flat. That said, if you store large objects in a single atom, you'll re-render every consumer on every partial change. Prefer small, focused atoms and compose them.

Derived State with Read-Only and Writable Derived Atoms

The second argument to atom() is where things get interesting. Pass a getter function and Jotai automatically tracks which atoms you read inside it — any change to those atoms re-runs the getter and notifies consumers. No useMemo, no createSelector, no manual dependency arrays.

const firstNameAtom = atom('Jane')
const lastNameAtom = atom('Doe')

// Read-only derived atom
const fullNameAtom = atom(get => `${get(firstNameAtom)} ${get(lastNameAtom)}`)

function DisplayName() {
  const fullName = useAtomValue(fullNameAtom)
  return <p>{fullName}</p>
}

Read-only derived atoms are great, but writable derived atoms are where the pattern really shines. You pass both a getter *and* a setter. The setter receives get, set, and whatever value the consumer passes in. This lets you build atoms that feel like normal state to consumers but actually coordinate updates across multiple atoms under the hood.

const cartItemsAtom = atom<CartItem[]>([])
const discountAtom = atom(0)

// Writable derived atom that clears both
const resetCartAtom = atom(
  get => get(cartItemsAtom),  // read value comes from cartItemsAtom
  (_get, set) => {
    set(cartItemsAtom, [])
    set(discountAtom, 0)
  }
)

function ClearCartButton() {
  const [, resetCart] = useAtom(resetCartAtom)
  return <button onClick={resetCart}>Clear</button>
}

Look, this pattern eliminates a ton of imperative orchestration code. Instead of a clearCart function in a Zustand action that knows about five different slices, each derived atom encapsulates its own coordination. The logic lives with the data, not in an arbitrary action file.

Async Atoms and Suspense

Jotai's async support is elegant in a way that most state libraries still haven't nailed in 2026. You return a Promise from an atom getter, wrap the consumer in <Suspense>, and you're done. No loading flags, no isLoading booleans scattered across the component.

import { atom, useAtomValue } from 'jotai'

const userIdAtom = atom(42)

const userAtom = atom(async get => {
  const id = get(userIdAtom)
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('User not found')
  return res.json()
})

function UserCard() {
  // useAtomValue suspends until the promise resolves
  const user = useAtomValue(userAtom)
  return <div>{user.name}</div>
}

// In parent:
// <Suspense fallback={<Skeleton />}>
//   <UserCard />
// </Suspense>

The key thing here: userAtom depends on userIdAtom. Change userIdAtom and the async atom automatically re-fetches. You get reactive data fetching with zero extra setup. It's not quite TanStack Query territory — you don't get caching, deduplication, or stale-while-revalidate out of the box — but for simple cases where you control the fetch lifecycle, it's 80% of the solution at 10% of the complexity.

For error states, pair your async atoms with React's error boundaries. Throw from the async getter and the boundary catches it. That's the React 18 mental model; Jotai embraces it fully rather than fighting it with its own error-state flags. One more thing — if you need to manually trigger a re-fetch, the atomWithRefresh pattern (from jotai/utils) adds a refresh atom you can call explicitly.

import { atom } from 'jotai'
import { atomWithRefresh } from 'jotai/utils'

const userAtom = atomWithRefresh(async get => {
  const id = get(userIdAtom)
  const res = await fetch(`/api/users/${id}`)
  return res.json()
})

// In component:
// const refreshUser = useSetAtom(userAtom) — calling it re-fetches

The Molecule Pattern: Scoped Atom Families

Here's the pattern you don't see written about enough. Atoms at module scope are global — every instance of a component shares the same atom. That's fine for global UI state, but what if you have multiple independent instances of, say, a todo list widget, each with its own state? You need scoped atoms.

The molecule pattern (popularized by the jotai-molecules library, though you can roll your own) gives you a factory that produces a fresh set of atoms per scope. Think of it like atom-factories keyed by some identity — a list ID, a tab index, whatever makes sense for your domain.

import { atom } from 'jotai'

// atom family — returns the same atom for the same key
const itemsCache = new Map<string, ReturnType<typeof atom>>()

function listAtomsForId(listId: string) {
  if (!itemsCache.has(listId)) {
    itemsCache.set(listId, atom<string[]>([]))
  }
  return itemsCache.get(listId)!
}

function TodoList({ listId }: { listId: string }) {
  const [items, setItems] = useAtom(listAtomsForId(listId))
  // ... render items
}

The official approach uses atomFamily from jotai/utils, which handles cache management, equality comparison, and cleanup. For most use cases, that's what you want instead of the manual map above.

import { atomFamily } from 'jotai/utils'

const todoListAtomFamily = atomFamily((listId: string) =>
  atom<string[]>([])
)

// Usage
const [items, setItems] = useAtom(todoListAtomFamily('my-list-1'))

In practice, molecule pattern usage shows up constantly in component-library work. When you're building something like a multi-step form wizard or a dashboard with 12 independent widgets — the kind of complex interactive components you'd find in a template — scoped atoms keep each widget's state isolated without any Context gymnastics. It's genuinely one of those patterns that you'll wonder how you lived without once you start using it.

Jotai DevTools and Debugging

One common knock on Jotai is that debugging is harder than Redux. That's... partially fair, but the situation has improved a lot. The jotai-devtools package gives you a React component you drop into your app that renders an atom inspector panel — you can see current values, historical changes, and which components are subscribed.

import { DevTools } from 'jotai-devtools'
import 'jotai-devtools/styles.css'

// In your root layout, dev-only
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        {process.env.NODE_ENV === 'development' && <DevTools />}
      </body>
    </html>
  )
}

You can also label atoms with a debugLabel property — that's the first thing you should do on any atom that isn't obviously named. Without labels, the DevTools panel shows atoms as atom1, atom2, etc., which is useless at scale.

const cartAtom = atom<CartItem[]>([])
cartAtom.debugLabel = 'cart/items'

const discountAtom = atom(0)
discountAtom.debugLabel = 'cart/discount'

Worth noting: naming atoms with a namespace prefix like cart/items makes the inspector much easier to scan when you have 50+ atoms in flight. It's a 10-second habit that saves 10-minute debugging sessions. The DevTools panel also works with async atoms and shows you pending vs resolved state, which is the part that would otherwise require console.log abuse.

Integrating Jotai with Empire UI Components

Most UI component libraries don't care how you manage state — they expose controlled props and you wire them up however you like. But when you're using Empire UI components that have richer interactive state (think multi-step modals, animated tabs, drawer panels), Jotai slots in cleanly.

A pattern that works well: define atoms for your UI state co-located near the component that owns them, then expose only the atoms (not internal state or refs) to child components that need to react. The alternative — prop-drilling open/close state through three layers — is the exact thing Jotai was designed to kill.

// modalAtoms.ts
const isCheckoutModalOpenAtom = atom(false)
const checkoutStepAtom = atom<'cart' | 'shipping' | 'payment'>('cart')

// Derived: can the user advance to next step?
const canAdvanceAtom = atom(get => {
  const step = get(checkoutStepAtom)
  const items = get(cartItemsAtom)
  if (step === 'cart') return items.length > 0
  return true
})

export { isCheckoutModalOpenAtom, checkoutStepAtom, canAdvanceAtom }

Then any component — nav bar trigger, the modal itself, a floating cart indicator — can read or write those atoms without being wired through a shared parent. That's the kind of architecture that makes large interactive UIs manageable without reaching for a global event bus or Context acrobatics.

If you're building a UI-heavy app and spending time on how components look, it's worth pairing good state architecture with good visual tooling. The glassmorphism generator and gradient generator can handle the CSS side while Jotai handles the data side — they're solving completely different problems, but having both figured out means you're not context-switching between 'how does this component look' and 'where does this state live' at the same time.

FAQ

Do I need a Provider to use Jotai?

No. Jotai v2 uses a default store that's implicitly available everywhere in your app. You only add a Provider if you need isolated atom scopes — like rendering the same component tree twice with independent state, useful for tests or embedded widgets.

How does Jotai handle TypeScript?

Really well. atom<T>(initialValue) infers the type automatically from the initial value in most cases. Async atoms return Awaited<T> from the getter, and derived atoms infer based on what you return. You rarely need explicit type annotations.

Can Jotai replace TanStack Query for data fetching?

For simple one-off fetches yes, but TanStack Query still wins on anything that needs caching, deduplication, background refetching, or pagination. Use async atoms for UI-driven state that happens to involve a fetch; use TanStack Query for server-state that lives independently of component lifecycle.

What's the difference between atomFamily and the molecule pattern?

atomFamily (from jotai/utils) is a utility that creates a map of atoms keyed by a parameter — it handles cache and equality for you. The molecule pattern is a broader design pattern where you group related atoms into a factory and scope them to a React subtree using Context. atomFamily is simpler; molecules give you finer-grained scoping control.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Zustand in React: Simple State Management That Gets Out of Your WayZustand Guide 2026: Slices, Immer Middleware, DevTools, PersistZustand vs Jotai in 2026: Atomic vs Single Store StateTanStack Query vs Redux Toolkit: Server State vs Global State