Stately

Compare to Jotai

This page compares XState Store atoms with Jotai.

Summary

TopicJotaiXState Store atoms
State modelAtom configs whose values live in a Jotai store/provider.Live atom instances with get, set, and subscribe.
Derived stateDerived atoms read other atoms with get.Derived atoms read other atoms with .get().
AsyncAsync atoms can integrate with Suspense and loadable.createAsyncAtom(...) returns explicit pending/done/error snapshots.
RenderingReact-focused useAtom family.Framework adapters plus core subscriptions.
Store modelAtom graph first.Atom graph plus evented stores for transitions.

State Model

Jotai is atom-first. Its atom(...) function creates atom configs; atom values live in a Jotai store/provider.

XState Store atoms are live readable/subscribable values:

const countAtom = atom(0);

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

  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
const count = createAtom(0);

count.get();
count.set((value) => value + 1);
count.subscribe((value) => {
  console.log(value);
});

This makes XState Store atoms usable outside a framework adapter. Framework packages add hooks on top.

Migration tip: A primitive Jotai atom usually maps directly to createAtom(initialValue).

Derived State

Jotai's strongest conceptual overlap with XState Store is derived atoms. Jotai derived atoms read dependencies with get(...); XState Store derived atoms read dependencies directly with .get():

const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);
const count = createAtom(0);
const doubled = createAtom(() => count.get() * 2);

Both models support granular derived state. This is not a case where XState Store needs a separate selector-only store to match Jotai's core advantage; XState Store atoms are the comparison point.

Migration tip: Replace Jotai's get(otherAtom) reads with direct otherAtom.get() calls in derived atoms.

Update Model

Jotai supports writable atoms, including write-only and read-write derived atoms.

XState Store atoms support direct writes with atom.set(...), reducer-style writes with createReducerAtom(...), and evented domain updates with createStore(...):

const countAtom = atom(0);
const incAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1);
});
const count = createReducerAtom(0, (state, event: { type: 'inc' }) => {
  if (event.type === 'inc') {
    return state + 1;
  }

  return state;
});

count.send({ type: 'inc' });

Use atoms for direct granular state. Use stores when you want named events, transition checks, schemas, emitted events, and extensions.

Migration tip: Writable atoms map to atom.set(...) when they are simple, createReducerAtom(...) when they are event-shaped, and createStore(...) when the update is a domain transition.

Effects Model

Jotai often models async as atom reads or writes. It integrates closely with React Suspense, and loadable can represent loading/error/data states without Suspense.

XState Store has explicit async atom snapshots:

const userAtom = atom(async () => {
  const response = await fetch('/api/user');
  return response.json();
});
const user = createAsyncAtom(async ({ signal }) => {
  const response = await fetch('/api/user', { signal });
  return response.json();
});

For evented effects, XState Store uses store transitions and enqueue.effect(...). That separates direct atom state from event-caused side effects.

Integration And Rendering

Jotai is React-focused. Its hook model is very direct for React apps.

XState Store atoms are framework-neutral and can be used directly or through adapters such as @xstate/store-react, @xstate/store-vue, and @xstate/store-solid.

Both models can be granular. Jotai gets granularity from atom dependencies in React. XState Store gets granularity from atom subscriptions, derived atoms, and adapter selectors.

function Counter() {
  const [count] = useAtom(countAtom);
  return <span>{count}</span>;
}
function Counter() {
  const count = useAtom(countAtom);
  return <span>{count}</span>;
}

Extensions

Jotai has a broad utility ecosystem for atom patterns.

XState Store's advantage is that atoms live next to evented stores and first-party extensions:

  • persistence
  • undo/redo
  • reset
  • schema validation

Those extensions apply to stores rather than replacing the atom graph.

const persistedAtom = atomWithStorage('count', 0);
const store = createStore({
  context: { count: 0 },
  on: {},
}).with(persist({ name: 'count' }));

Types And Runtime Contracts

Jotai's atom types are strong and local to the atom definitions.

XState Store atom types are similarly local for atoms, and stores add schema-driven event/context/emitted-event typing plus optional runtime validation.

const countAtom = atom<number>(0);
const count = createAtom(0);

const store = createStore({
  schemas: {
    events: {
      inc: z.object({ by: z.number() }),
    },
  },
  context: { count: 0 },
  on: {},
});

When To Choose Jotai

Choose Jotai when:

  • your app is React-first
  • the atom graph is the main architecture
  • you want Jotai's mature atom utility ecosystem
  • Suspense-oriented async atoms are the desired UI model

When To Choose XState Store

Choose XState Store when:

  • you want atom-style granular state and evented stores in one package
  • you need framework-neutral atoms
  • direct atom updates are not enough for some domain workflows
  • you want schemas, emitted events, transition checks, and store extensions alongside atoms

Migrating from Jotai

Concept map

JotaiXState Store
primitive atomcreateAtom(initialValue)
derived atomcreateAtom(() => otherAtom.get())
writable atomatom.set(...), createReducerAtom(...), or store transition
async atomcreateAsyncAtom(...)
useAtom(...)framework adapter useAtom(...)

Before

const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);
const countAtom = createAtom(0);
const doubledAtom = createAtom(() => countAtom.get() * 2);

For write atoms with event-like updates, use a reducer atom:

const countAtom = createReducerAtom(0, (count, event: { type: 'inc' }) => {
  if (event.type === 'inc') {
    return count + 1;
  }

  return count;
});

On this page