Atomic State in React with Jotai: Derived, Async and Write-Only Atoms, Families and Provider Scope

Key takeaways

Jotai is a primitive and flexible state management library for React. It takes an atomic approach to global state with a minimal API inspired by Recoil.

Introduction

Jotai is a primitive and flexible state management solution for React. It takes an atomic approach to global React state, inspired by Recoil with a more minimal API.

The word “atomic” is not marketing language here — it describes a genuinely different mental model from what most React developers learn first. Redux (and Redux Toolkit) teaches you to think in terms of one store that holds your entire application’s state as a single object tree, with components subscribing to slices of that tree through selectors. Jotai flips this around: instead of one big object that you carve into slices, you build state out of many small, independent atoms, and you compose them together — sometimes into derived atoms, sometimes just by using several atoms in the same component. The store, in Jotai’s world, is an implementation detail that exists to hold atom values; it’s not something you design around.

This distinction matters more than it sounds like it should, because it changes how re-renders work, how you reason about “what depends on what,” and how you scale state as an application grows. This guide walks through Jotai’s core primitives, then goes deeper into the parts that trip people up in production: how the dependency graph is actually built and re-evaluated, where atom identity causes subtle re-render bugs, how Provider scoping trades off isolation against boilerplate, and when you should reach for Jotai versus Zustand or Redux Toolkit instead.

The Problem

To understand why Jotai’s atomic model exists, it helps to look at what it’s replacing. React’s built-in Context API is the “native” way to share state across a component tree, but it has a structural limitation: a Context.Provider broadcasts its entire value to every consumer whenever that value changes, regardless of which part of the value a given consumer actually reads.

Context API:

const UserContext = createContext();

function App() {
  const [user, setUser] = useState({ name: 'Alice', age: 30 });
  
  return (
    <UserContext.Provider value={user}>
      <ComponentA /> {/* Re-renders on ANY user change */}
      <ComponentB /> {/* Re-renders on ANY user change */}
    </UserContext.Provider>
  );
}

Even if ComponentA only reads user.name and ComponentB only reads user.age, updating age re-renders both, because React Context has no concept of “which field did this consumer actually touch.” The usual workaround — splitting one big context into several smaller contexts — works, but it means you have to predict in advance every independent axis of change and hand-author a context for each one. That doesn’t scale past a handful of values.

Jotai:

const nameAtom = atom('Alice');
const ageAtom = atom(30);

function ComponentA() {
  const [name] = useAtom(nameAtom);
  return <div>{name}</div>; // Only re-renders when name changes
}

function ComponentB() {
  const [age] = useAtom(ageAtom);
  return <div>{age}</div>; // Only re-renders when age changes
}

Jotai solves the same problem from the opposite direction. Instead of splitting a big value into contexts up front, you start with the smallest independent unit of state — the atom — and each component subscribes only to the exact atoms it reads. There’s no selector function needed to carve out a slice, because the “slice” and the “atom” are the same thing. This is the crux of the bottom-up philosophy: granularity is the default, not something you retrofit after a performance problem shows up in production.

Installation

npm install jotai

Jotai ships as a small, dependency-free package (the core is a few kilobytes gzipped). Unlike Redux Toolkit, there’s no store configuration step, no root reducer, and no middleware pipeline to wire up before you can use it — you import atom, declare a value, and start using it in components immediately. That low ceremony is part of the appeal for small-to-medium apps, but it’s worth remembering as you read the rest of this guide: less ceremony also means fewer guardrails, so patterns that Redux enforces structurally (like normalized state, or single-direction data flow through reducers) are conventions you have to choose to follow with Jotai.

Atoms (Primitive State)

Basic Atom

import { atom, useAtom } from 'jotai';

// Define atom
const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(c => c + 1)}>Increment (updater)</button>
    </div>
  );
}

An atom() call doesn’t create state by itself — it creates a config object: a definition that says “here is a piece of state, and here is its initial value (or how to derive it).” The actual value lives in a Jotai store, keyed by the atom’s identity (effectively a WeakMap keyed by the atom object reference). useAtom(countAtom) is what connects a component to that store entry, giving you a [value, setValue] pair analogous to useState, except the state now lives outside the component and can be shared by any other component that imports the same atom reference.

This is why atoms are almost always declared at module scope, outside of any component — the atom itself is just a stable, unique key. If you accidentally create a new atom inside a render function (const countAtom = atom(0) inside a component body), you get a brand-new, disconnected piece of state on every render, which silently breaks sharing between components and resets to the initial value constantly. It’s the single most common beginner mistake with Jotai and worth internalizing early: the atom is the identity; the store is where the value lives.

Read-only Hook

import { useAtomValue } from 'jotai';

function Display() {
  const count = useAtomValue(countAtom); // Read-only
  return <div>{count}</div>;
}

Write-only Hook

import { useSetAtom } from 'jotai';

function Controls() {
  const setCount = useSetAtom(countAtom); // Write-only
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Increment
    </button>
  );
}

Splitting useAtom into useAtomValue and useSetAtom isn’t just API sugar — it’s a deliberate re-render optimization that has no direct equivalent in useState. useSetAtom returns a stable setter function that never changes identity and, critically, does not subscribe the component to the atom’s value at all. A Controls component that only calls setCount will never re-render when count changes, because it never reads count in the first place. Compare this to Redux, where dispatch is already stable and doesn’t cause re-renders on its own — the difference is that in Jotai you have to make the same choice explicitly for every atom you touch, since useAtom bundles read and write together by default. In practice, treat useAtom as the convenience shorthand for components that genuinely need both, and reach for the split hooks whenever a component only reads or only writes — it’s a cheap habit that avoids a class of unnecessary re-renders you’d otherwise only catch with a profiler.

Derived Atoms

import { atom } from 'jotai';

const priceAtom = atom(100);
const quantityAtom = atom(2);

// Read-only derived atom
const totalAtom = atom((get) => {
  const price = get(priceAtom);
  const quantity = get(quantityAtom);
  return price * quantity;
});

function Cart() {
  const [price, setPrice] = useAtom(priceAtom);
  const [quantity, setQuantity] = useAtom(quantityAtom);
  const total = useAtomValue(totalAtom);
  
  return (
    <div>
      <input value={price} onChange={e => setPrice(+e.target.value)} />
      <input value={quantity} onChange={e => setQuantity(+e.target.value)} />
      <p>Total: ${total}</p>
    </div>
  );
}

This is where the “atomic” model earns its keep. totalAtom isn’t state you manually keep in sync with priceAtom and quantityAtom — it’s a pure function of other atoms, and Jotai builds a dependency graph by watching which atoms you call get() on during that function’s execution. When either priceAtom or quantityAtom changes, Jotai already knows totalAtom depends on both, so it recomputes totalAtom and re-renders only the components subscribed to it. You never write a useEffect to keep total synchronized — there is no synchronization step, because totalAtom is defined declaratively as a derivation, not imperatively updated as a side effect.

The dependency graph is also dynamic, not fixed at declaration time — if your derive function conditionally calls get(atomA) in one branch and get(atomB) in another, Jotai only subscribes to whichever atoms were actually read on the last evaluation. This is powerful for things like conditional data fetching, but it does mean the effective dependency set can change between evaluations, so it pays to keep derive functions predictable rather than branching on external, non-atom state.

flowchart LR
    price[priceAtom] --> total[totalAtom]
    qty[quantityAtom] --> total
    total --> cart["Cart component<br/>(re-renders on total change)"]
    price --> priceInput["price input<br/>(re-renders on price change)"]
    qty --> qtyInput["quantity input<br/>(re-renders on quantity change)"]

The diagram above is the mental model to keep: each arrow is a subscription Jotai tracked automatically. Only the nodes downstream of whatever changed actually re-render — updating quantityAtom recomputes totalAtom and re-renders Cart and the quantity input, but leaves the price input untouched, even though all three components live inside the same tree.

Writable Derived Atoms

const celsiusAtom = atom(0);

const fahrenheitAtom = atom(
  (get) => get(celsiusAtom) * 9/5 + 32, // Read
  (get, set, newValue) => {             // Write
    set(celsiusAtom, (newValue - 32) * 5/9);
  }
);

function Temperature() {
  const [celsius, setCelsius] = useAtom(celsiusAtom);
  const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom);
  
  return (
    <div>
      <input value={celsius} onChange={e => setCelsius(+e.target.value)} />°C
      <input value={fahrenheit} onChange={e => setFahrenheit(+e.target.value)} />°F
    </div>
  );
}

A writable derived atom is a two-way mapping rather than a one-way computation: reading it evaluates the get function, but writing to it runs the set function, which is free to redirect the write anywhere — typically back to the atom(s) it’s derived from. There is deliberately no independent storage for fahrenheitAtom; it never “remembers” a Fahrenheit value on its own. Calling setFahrenheit(212) converts to Celsius and writes celsiusAtom, which then makes fahrenheitAtom recompute back to 212 on the next read. This pattern is genuinely useful any time you have one canonical source of truth but want multiple ergonomic “views” onto it — unit conversions, formatted vs. raw form fields, or a normalized ID list vs. a computed count — without duplicating state or risking the two copies drifting out of sync.

Async Atoms

const userIdAtom = atom(1);

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

function UserProfile() {
  const [user] = useAtom(userAtom);
  
  return <div>{user.name}</div>;
}

With Suspense

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  );
}

Async atoms are where Jotai’s design pays off in a way that’s hard to replicate with Redux or plain useState + useEffect. When a derive function returns a Promise, useAtom/useAtomValue suspend the component (by throwing the Promise, the same mechanism React’s own Suspense relies on) until it resolves, then resume with the resolved value. There’s no isLoading boolean to manage, no useEffect cleanup to guard against setting state after unmount, and no manual cancellation logic for the common case — React’s Suspense boundary owns the loading UI.

Because userAtom reads userIdAtom through get(), changing userIdAtom automatically triggers a re-fetch — the dependency tracking that powers synchronous derived atoms works identically for async ones. This is convenient, but it’s also the source of the most common async-atom pitfall: rapid dependency changes can produce out-of-order responses. If a user changes userIdAtom from 1 to 2 before the fetch for 1 resolves, both requests are in flight, and depending on network timing the response for 1 can resolve after the response for 2, briefly showing stale data. Jotai doesn’t automatically cancel the earlier request for you — if that matters for your use case, pass an AbortSignal obtained via get.signal (available in recent Jotai versions) into fetch, or wrap the fetch in a small state machine that ignores stale responses by checking whether the atom’s dependency value is still current when the response arrives. For anything beyond a simple “fetch on ID change” pattern — retries, caching, background refetch — most teams pair Jotai with a dedicated data-fetching library like TanStack Query and use atomWithQuery-style bindings rather than hand-rolling caching logic inside a derive function.

Actions (Write-only Atoms)

const todosAtom = atom([]);

const addTodoAtom = atom(
  null, // No read
  (get, set, text: string) => {
    const todos = get(todosAtom);
    set(todosAtom, [...todos, { id: Date.now(), text, done: false }]);
  }
);

function TodoForm() {
  const addTodo = useSetAtom(addTodoAtom);
  const [text, setText] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    addTodo(text);
    setText('');
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button type="submit">Add</button>
    </form>
  );
}

Passing null as the first argument to atom() declares an atom with no readable value — it exists purely as a callable write function, which is the closest Jotai equivalent to a Redux action creator plus reducer, fused into one unit. The advantage over calling set(todosAtom, ...) directly inside a component is encapsulation: the logic for “how do I correctly append a todo” lives next to the atom it mutates, not scattered across every component that needs to add one. If you later change todosAtom’s shape (say, from an array to a normalized { byId, allIds } structure), you only need to update addTodoAtom’s body — every component calling useSetAtom(addTodoAtom) keeps working unchanged. This is effectively how you get Redux-style “actions as the only way to mutate state” discipline in Jotai, without a reducer switch statement or dispatch machinery — it’s a convention the atom model supports well but doesn’t force on you.

Atom Families

import { atomFamily } from 'jotai/utils';

const userAtomFamily = atomFamily((id: number) =>
  atom(async () => {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  })
);

function UserProfile({ userId }: { userId: number }) {
  const [user] = useAtom(userAtomFamily(userId));
  return <div>{user.name}</div>;
}

atomFamily is a factory that memoizes atoms by parameter, so userAtomFamily(1) returns the same atom instance every time it’s called with 1, rather than creating a new one. This matters because atom identity is the subscription key — if userAtomFamily(1) returned a new atom object on each call, every component rendering <UserProfile userId={1} /> would subscribe to a different atom, defeating the entire purpose of sharing state per ID.

The default memoization uses Object.is (strict reference/value equality) on the parameter, which works cleanly for primitives like numbers and strings but is a common trap when the parameter is an object: atomFamily(({ id }) => ...) called with two structurally-identical but referentially-different objects ({ id: 1 } twice, from two different call sites) produces two separate atoms, silently breaking the sharing you expected. If you need object parameters, pass a custom equality function as atomFamily’s second argument (e.g., a shallow-equal comparator), or normalize the parameter to a primitive (like a stringified key) before calling the family. It’s also worth knowing that atomFamily atoms are cached indefinitely by default — for families keyed by something unbounded (user-generated IDs, search queries), call .remove(param) when an entry is no longer needed, or you’ll accumulate atoms for the lifetime of the page.

Utils

atomWithStorage

import { atomWithStorage } from 'jotai/utils';

const darkModeAtom = atomWithStorage('darkMode', false);

function ThemeToggle() {
  const [darkMode, setDarkMode] = useAtom(darkModeAtom);
  
  return (
    <button onClick={() => setDarkMode(!darkMode)}>
      {darkMode ? 'Light' : 'Dark'} Mode
    </button>
  );
}

atomWithStorage behaves like a normal atom for read/write purposes, but persists its value to localStorage (or a custom storage adapter you supply, including sessionStorage or AsyncStorage in React Native) under the given key, and reads the initial value back from storage on creation. It also synchronizes across browser tabs by listening for the storage event, so toggling dark mode in one tab updates the atom’s value in every other open tab automatically — something you’d otherwise have to wire up by hand.

The pitfall to know about is server-side rendering. localStorage doesn’t exist on the server, so atomWithStorage falls back to the provided initial value during SSR and only reads the real persisted value after hydration on the client. If your app renders based on darkModeAtom during SSR, you can get a visible flash where the server-rendered markup shows the default (false) and then flips to the user’s actual saved preference immediately after hydration — the same class of hydration-mismatch issue you’d see with any client-only storage API. Jotai’s atomWithStorage accepts a getOnInit option to read the value eagerly where the storage API is synchronously available, but for frameworks with SSR you generally still need a small flash-prevention strategy (an inline script that sets a data-theme attribute before React hydrates is the usual fix, independent of Jotai itself).

atomWithReducer

import { atomWithReducer } from 'jotai/utils';

const countReducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    default: return state;
  }
};

const countAtom = atomWithReducer(0, countReducer);

function Counter() {
  const [count, dispatch] = useAtom(countAtom);
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  );
}

atomWithReducer exists specifically for teams migrating from Redux, or for state transitions complex enough that a reducer’s explicit action-type switch is more auditable than a pile of separate write-only atoms. It’s a thin wrapper: internally it’s still a single atom holding state, and dispatch is just useSetAtom under a different name that runs your reducer function before writing the result. You lose none of Jotai’s fine-grained subscription behavior by using it — components that don’t read countAtom still won’t re-render when you dispatch — but you gain the reducer pattern’s testability (a reducer is a pure function you can unit test with plain input/output assertions, with no atoms or React involved at all).

atomWithReset

import { atomWithReset, useResetAtom } from 'jotai/utils';

const filterAtom = atomWithReset('');

function SearchFilter() {
  const [filter, setFilter] = useAtom(filterAtom);
  const resetFilter = useResetAtom(filterAtom);
  
  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <button onClick={resetFilter}>Clear</button>
    </div>
  );
}

atomWithReset remembers its original initial value and exposes useResetAtom to snap back to it in one call, rather than requiring you to hardcode the “empty” value again at every reset site (setFilter('')). This is a small convenience, but it removes a real source of bugs: if the initial value ever changes (say, filterAtom starts defaulting to a value read from the URL instead of a literal empty string), every hardcoded reset call elsewhere in the codebase would silently go stale, whereas useResetAtom always resets to whatever the atom was actually initialized with.

Real-World Example: Todo App

import { atom, useAtom, useSetAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

const todosAtom = atomWithStorage<Todo[]>('todos', []);

const filterAtom = atom<'all' | 'active' | 'done'>('all');

const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);
  
  if (filter === 'all') return todos;
  return todos.filter(t => filter === 'done' ? t.done : !t.done);
});

const addTodoAtom = atom(
  null,
  (get, set, text: string) => {
    const todos = get(todosAtom);
    set(todosAtom, [...todos, { id: Date.now(), text, done: false }]);
  }
);

const toggleTodoAtom = atom(
  null,
  (get, set, id: number) => {
    const todos = get(todosAtom);
    set(todosAtom, todos.map(t => 
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }
);

function TodoApp() {
  const [text, setText] = useState('');
  const addTodo = useSetAtom(addTodoAtom);
  const todos = useAtomValue(filteredTodosAtom);
  const toggleTodo = useSetAtom(toggleTodoAtom);
  const [filter, setFilter] = useAtom(filterAtom);
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!text.trim()) return;
    addTodo(text);
    setText('');
  };
  
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={e => setText(e.target.value)} />
        <button type="submit">Add</button>
      </form>
      
      <div>
        <button onClick={() => setFilter('all')}>All</button>
        <button onClick={() => setFilter('active')}>Active</button>
        <button onClick={() => setFilter('done')}>Done</button>
      </div>
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
            {todo.done ? '✅' : '⬜'} {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

This example ties every primitive covered so far into one coherent structure, and it’s worth naming the pattern explicitly because it generalizes to almost any Jotai-based feature: one source-of-truth atom (todosAtom, persisted via atomWithStorage), one UI-state atom that doesn’t belong in persisted storage (filterAtom), one derived atom that combines them for rendering (filteredTodosAtom), and a handful of write-only action atoms (addTodoAtom, toggleTodoAtom) that own the logic for mutating the source of truth. No component ever mutates todosAtom directly — they all go through an action atom, which keeps the mutation logic in one place even though there’s no formal reducer enforcing that discipline.

Notice also that TodoApp reads filteredTodosAtom, not todosAtom directly — this is what keeps the todo list list re-rendering correctly when the filter changes, without needing to duplicate filtering logic inside the component. If a second component elsewhere in the tree also needed the filtered list (a counter badge showing “3 active”, for instance), it would import filteredTodosAtom and get the same computation for free, memoized by Jotai’s dependency tracking rather than recomputed and re-derived independently in each component.

Provider Scoping: Trade-offs

Jotai atoms work globally by default — if you call useAtom(countAtom) anywhere in your component tree without wrapping it in a <Provider>, every instance resolves against one implicit default store, shared app-wide. This is convenient and is why the earlier examples never mention Provider at all. But Jotai also supports explicit Provider scoping:

import { Provider } from 'jotai';

function App() {
  return (
    <>
      <Provider>
        <FormA /> {/* has its own isolated atom values */}
      </Provider>
      <Provider>
        <FormB /> {/* completely separate store from FormA */}
      </Provider>
    </>
  );
}

Each <Provider> creates an isolated store, so the same atom object used inside two different Provider subtrees holds two independent values. This is the mechanism for building reusable components that carry their own local Jotai state — a modal, a wizard step, a list-item editor — where you want fresh, independent state per mounted instance rather than one globally shared value. It’s structurally similar to why you’d use React Context for scoping rather than a single global variable: the atom definitions are shared and reusable across your codebase, but the values are scoped to wherever you mount a Provider.

The trade-off is that Provider scoping reintroduces some of the ceremony atoms are meant to avoid: components deep in a scoped subtree implicitly depend on being rendered under the correct Provider, which isn’t visible from the component’s own code, and can be easy to get wrong when a component is moved to a different part of the tree, or rendered via a portal outside its intended Provider. The general guidance that holds up in production: default to no Provider (the global store) unless you have a concrete reason to isolate state per component instance — most application-level global state (auth, theme, filters, cached queries) benefits from being a single shared store, and reaching for scoped providers everywhere “just in case” mostly adds indirection without adding safety.

Common Pitfalls

A few issues show up often enough in real Jotai codebases that they’re worth calling out directly, beyond what’s already been mentioned inline above:

  • Atom identity is the cache key — treat it as such. Any time an atom is created inline during render (as opposed to at module scope, or memoized via useMemo/atomFamily), you get a new, disconnected atom on every render. This is the root cause behind “my state keeps resetting” bug reports far more often than an actual Jotai defect.
  • Derived atoms re-run their entire body on any dependency change, not just the “relevant” part. If a derive function does expensive work (heavy filtering, sorting, formatting) on a large collection, and it also reads an unrelated fast-changing atom, every change to that unrelated atom re-triggers the expensive work. Split derive functions so expensive computation depends on the narrowest possible atom, or memoize the expensive part internally.
  • Object and array values written to atoms should be new references, not mutated in place. set(todosAtom, todos.map(...)) (creating a new array) is correct; calling .push() on the existing array and calling set(todosAtom, todos) with the same reference will not reliably trigger downstream re-evaluation, because Jotai (like React) uses reference equality to decide whether a value actually changed.
  • atomFamily parameters need stable equality, as covered above — prefer primitive keys or supply a custom equality function for object parameters.
  • Async atoms don’t cancel in-flight requests automatically when their dependencies change again before the first request resolves — guard against out-of-order responses explicitly if request ordering matters for your feature.

Jotai vs. Redux Toolkit vs. Zustand

These three cover most of the React state-management landscape today, and picking between them is less about raw capability (all three can build the same app) and more about which mental model fits your team and your state shape.

JotaiZustandRedux Toolkit
Mental modelBottom-up, atomicTop-down, single store (but usually just one flat store, not a tree)Top-down, single normalized store
Re-render granularityAutomatic, per-atomManual, via selectorsManual, via selectors (useSelector)
BoilerplateMinimalMinimalModerate (slices, actions, reducers) — though RTK cut this drastically vs. classic Redux
Async handlingSuspense-native, via async atom functionsPlain async functions inside actionsThunks / RTK Query
DevTools / time-travel debuggingBasic, via jotai-devtoolsBasic, via middlewareExcellent — the most mature ecosystem here
Best fitUI-heavy state with many independent, fine-grained pieces (forms, per-item toggles, derived computations)Small-to-medium global stores where a plain object plus a few actions is enoughLarge apps needing strict conventions, time-travel debugging, or an existing Redux investment

In practice: reach for Jotai when your state naturally decomposes into many small, loosely related pieces and you want re-render optimization “for free” without hand-writing selectors — form fields, per-row UI toggles, or any screen where different pieces of the UI update independently and often. Reach for Zustand when you want a single, simple store with a handful of actions and don’t need atom-level granularity — it reads closer to a plain JavaScript object with functions attached, which some teams find easier to onboard new engineers onto. Reach for Redux Toolkit when the project already has significant Redux investment, when strict unidirectional data flow and time-travel debugging are organizational requirements, or when the state is large, deeply normalized, and benefits from RTK Query’s caching layer. None of the three is strictly better — they encode different opinions about where structure should live, and the right choice tracks how your state is actually shaped more than any raw performance difference.

DevTools

npm install --save-dev jotai-devtools
import { DevTools } from 'jotai-devtools';

function App() {
  return (
    <>
      <DevTools />
      <YourApp />
    </>
  );
}

jotai-devtools renders an in-app panel listing every atom currently mounted in the store, its current value, and lets you edit values live for debugging — useful for quickly checking whether a derived atom’s dependency graph is wired the way you expect, without adding temporary console.log calls to derive functions. It’s intentionally lighter-weight than Redux DevTools’ full time-travel/action-replay experience; if you need action-level audit logs or time-travel debugging as a hard requirement, that’s one of the concrete reasons to lean toward Redux Toolkit instead, per the comparison above.

Reasoning about the atom graph as the app grows

Jotai gives you fine-grained re-render optimization automatically, as a consequence of how atoms are structured, rather than something you achieve manually with memoized selectors (as in Redux) or careful store slicing (as in Zustand). That automatic granularity is extremely valuable for UI-heavy state with many independent moving parts, but it also means the dependency graph — which atoms derive from which — becomes the thing you need to reason about carefully as an app grows, in the same way Redux developers learn to reason about their reducer tree and selector memoization.

A few rules follow from that. Atoms are keyed by object identity, so never create them inside a render function. Derived atoms track their dependencies automatically, and write-only atoms give you one place for mutation logic without a reducer. Async atoms plug into Suspense but do not cancel stale requests, so guard against out-of-order responses when timing matters. Most of the re-render savings come from splitting useAtomValue and useSetAtom and letting each component subscribe only to what it reads.


Frequently Asked Questions (FAQ)

Q. How do I read an async atom without suspending the whole component?

A. Wrap it with loadable from jotai/utils: useAtomValue(loadable(userAtom)) returns an object whose state is 'loading', 'hasData' or 'hasError', so you can render an inline spinner or error message instead of relying on a <Suspense> boundary. Create the loadable atom once at module scope rather than inside render, for the atom-identity reason described in Common Pitfalls. Also note that with the plain Suspense approach shown in this article, a rejected fetch is thrown during render, so you need an error boundary around <UserProfile /> as well.