Stately

Compare to Recoil

This page compares XState Store atoms with Recoil.

Summary

TopicRecoilXState Store atoms
State modelReact atom and selector graph.Framework-neutral atom and derived atom graph.
Derived stateSelectors derive from atoms/selectors.Derived atoms read other atoms/selectors with .get().
RenderingReact dependency graph determines subscriptions.Core subscriptions plus framework adapter selectors.
EffectsAtom effects and async selectors.Async atoms and store transition effects.
ScopeReact-specific.Core works outside React.

State Model

Recoil models state as a graph flowing from atoms through selectors into React components.

XState Store atoms model the same broad idea of small independent state values and derived values, but the core is not React-specific:

const firstNameState = atom({
  key: 'firstName',
  default: 'Ada',
});
const firstName = createAtom('Ada');
const lastName = createAtom('Lovelace');

const fullName = createAtom(() => {
  return `${firstName.get()} ${lastName.get()}`;
});

Migration tip: Recoil atoms usually map to createAtom(...); Recoil selectors usually map to derived atoms.

Derived State

Recoil selectors are derived state. They track atom/selector dependencies and notify subscribed components when the derived value changes.

XState Store derived atoms provide the same core derived-state capability:

const isEvenState = selector({
  key: 'isEven',
  get: ({ get }) => get(countState) % 2 === 0,
});
const count = createAtom(0);
const isEven = createAtom(() => count.get() % 2 === 0);

So the comparison is not "Recoil has granular derived state and XState Store does not." XState Store atoms are the corresponding feature.

Migration tip: Replace selector dependency reads with direct .get() calls inside a derived atom.

Update Model

Recoil atom updates happen through React hooks such as useSetRecoilState and writable selectors.

XState Store atom updates happen directly on the atom:

function Counter() {
  const setCount = useSetRecoilState(countState);
  return <button onClick={() => setCount((c) => c + 1)}>+</button>;
}
count.set((value) => value + 1);

For updates that should be domain events, XState Store uses stores:

const store = createStore({
  context: { count: 0 },
  on: {
    increment: (context, event: { by: number }) => ({
      count: context.count + event.by,
    }),
  },
});

Migration tip: Use atom.set(...) for local updates and store transitions for updates that should be named, checked with store.can.*(...), emitted, or validated.

Effects Model

Recoil supports async selectors and atom effects.

XState Store splits this into:

  • async atoms for explicit pending/done/error atom values
  • store transition effects with enqueue.effect(...)
  • emitted events with enqueue.emit(...)

That gives stores a deterministic transition surface while still supporting direct atom async state.

const userState = selector({
  key: 'user',
  get: 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();
});

Integration And Rendering

Recoil is React-specific. That is natural if your application is entirely React.

XState Store atoms and stores are framework-neutral. React, Vue, Svelte, Solid, Angular, and Preact adapters subscribe to the same core primitives.

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

Extensions

Recoil's model centers on the React atom graph.

XState Store pairs atoms with store extensions:

  • persistence
  • undo/redo
  • reset
  • schema validation

These are most useful when state changes through named store events.

const persistedState = atom({
  key: 'persisted',
  default: 0,
  effects: [persistEffect],
});
const store = createStore({
  context: { count: 0 },
  on: {},
}).with(persist({ name: 'counter' }));

Types And Runtime Contracts

Recoil atom and selector types are local to atom/selector definitions.

XState Store atoms have local atom types, and stores add typed events, schema metadata, emitted events, and optional runtime validation.

const countState = atom<number>({
  key: 'count',
  default: 0,
});
const count = createAtom(0);

const store = createStore({
  context: { count: 0 },
  on: {
    inc: (context, event: { by: number }) => ({
      count: context.count + event.by,
    }),
  },
});

When To Choose Recoil

Choose Recoil when:

  • your app is React-only
  • you specifically want Recoil's atom/selector graph and APIs
  • your state architecture is mostly derived React state

When To Choose XState Store

Choose XState Store when:

  • you want atom-style derived state outside React
  • you need framework adapters beyond React
  • parts of your state are better modeled as events and transitions
  • you want atom state and evented store state in the same library

Migrating from Recoil

Concept map

RecoilXState Store
atomcreateAtom(initialValue)
selectorderived createAtom(...)
writable selectoratom.set(...), createReducerAtom(...), or store transition
async selectorcreateAsyncAtom(...)
React hook subscriptionframework adapter useAtom(...) or useSelector(...)

Before

const countState = atom({
  key: 'count',
  default: 0,
});

const doubledState = selector({
  key: 'doubled',
  get: ({ get }) => get(countState) * 2,
});
const count = createAtom(0);
const doubled = createAtom(() => count.get() * 2);

For updates that are really application events, move them into a store:

const store = createStore({
  context: { count: 0 },
  on: {
    increment: (context, event: { by: number }) => ({
      count: context.count + event.by,
    }),
  },
});

On this page