@xstate/store
Version 3.x (Version 2.x docs)
XState Store is a small library for simple state management in JavaScript/TypeScript applications. It is meant for updating store data using events for vanilla JavaScript/TypeScript apps, React apps, and more. It is comparable to libraries like Zustand, Redux, and Pinia. For more complex state management, you should use XState instead, or you can use XState Store with XState.
Installation
- npm
- pnpm
- yarn
npm install @xstate/store
pnpm install @xstate/store
yarn add @xstate/store
Quick start
import { createStore } from '@xstate/store';
const store = createStore({
// Initial context
context: { count: 0, name: 'David' },
// Transitions
on: {
inc: (context) => ({
...context,
count: context.count + 1,
}),
add: (context, event: { num: number }) => ({
...context,
count: context.count + event.num,
}),
changeName: (context, event: { newName: string }) => ({
...context,
name: event.newName,
}),
},
});
// Get the current state (snapshot)
console.log(store.getSnapshot());
// => {
// status: 'active',
// context: { count: 0, name: 'David' }
// }
// Subscribe to snapshot changes
store.subscribe((snapshot) => {
console.log(snapshot.context);
});
// Send an event (traditional way)
store.send({ type: 'inc' });
// logs { count: 1, name: 'David' }
// Send an event using the fluent trigger API
store.trigger.add({ num: 10 });
// logs { count: 11, name: 'David' }
store.trigger.changeName({ newName: 'Jenny' });
// logs { count: 11, name: 'Jenny' }
Creating a store
To create a store, pass a configuration object to the createStore(…) function with:
- The initial
context - An
onobject for transitions where the keys are event types and the values are context update functions
When updating context in transitions, you must return the complete context object with all properties:
import { createStore } from '@xstate/store';
const store = createStore({
context: { count: 0, name: 'David' },
on: {
inc: (context) => ({
...context, // Preserve other context properties
count: context.count + 1,
}),
},
});
Effects and Side Effects
You can enqueue effects in state transitions using the enqueue argument:
import { createStore } from '@xstate/store';
const store = createStore({
context: { count: 0 },
on: {
incrementDelayed: (context, event, enqueue) => {
enqueue.effect(() => {
setTimeout(() => {
store.send({ type: 'increment' });
}, 1000);
});
return context;
},
increment: (context) => ({
...context,
count: context.count + 1,
}),
},
});
Emitting Events
You can emit events from transitions by defining them in the emits property and using enqueue.emit:
import { createStore } from '@xstate/store';
const store = createStore({
context: { count: 0 },
emits: {
increased: (payload: { by: number }) => {
// Optional side effects can go here
},
},
on: {
inc: (context, event: { by: number }, enqueue) => {
enqueue.emit.increased({ by: event.by });
return {
...context,
count: context.count + event.by,
};
},
},
});
// Listen for emitted events
store.on('increased', (event) => {
console.log(`Count increased by ${event.by}`);
});
Pure transitions
You can use store.transition(state, event) to compute a tuple of the next state and any effects from a given state and event. This is useful for debugging and testing, or for having full control over the state transitions in your application.
const store = createStore({
context: { count: 0 },
emits: {
incremented: (payload: { by: number }) => {},
},
on: {
inc: (context, event: { by: number }, enqueue) => {
enqueue.emit.incremented({ by: event.by });
enqueue.effect(() => {
setTimeout(() => {
store.send({ type: 'increment' });
}, 1000);
});
return {
...context,
count: context.count + event.by,
};
},
},
});
const snapshot = store.getSnapshot();
const [nextState, effects] = store.transition(snapshot, {
type: 'inc',
by: 1,
});
console.log(nextState.context);
// => { count: 1 }
console.log(effects);
// => [
// { type: 'incremented', by: 1 },
// Function
// ]
// The store's state is unchanged
console.log(store.getSnapshot().context);
// => { count: 0 }
If you need to determine the next state from the store's initial state, you can get the initial snapshot using store.getInitialSnapshot():
const initialSnapshot = store.getInitialSnapshot();
const [nextState, effects] = store.transition(initialSnapshot, {
type: 'inc',
by: 1,
});
Selectors
Store selectors provide an efficient way to select and subscribe to specific parts of your store's state. With store selectors, you can:
- Get the current value of a specific part of state via
selector.get() - Subscribe to changes of only that specific part via
selector.subscribe(observer) - Optimize performance by only notifying subscribers when the selected value actually changes via
selector.subscribe(observer, equalityFn)
You can create a selector using store.select(selector):
import { createStore } from '@xstate/store';
const store = createStore({
context: {
position: { x: 0, y: 0 },
name: 'John',
age: 30,
},
on: {
positionUpdated: (
context,
event: { position: { x: number; y: number } },
) => ({
...context,
position: event.position,
}),
},
});
// Create a selector for the position
const position = store.select((context) => context.position);
// Get current value
console.log(position.get()); // { x: 0, y: 0 }
// Subscribe to changes
position.subscribe((position) => {
console.log('Position updated:', position);
});
// When position updates, only position subscribers are notified
store.trigger.positionUpdated({ position: { x: 100, y: 200 } });
// Logs: Position updated: { x: 100, y: 200 }
Custom Equality Functions
You can provide a custom equality function as the second argument to store.select(selector, equalityFn) to control when subscribers should be notified:
const position = store.select(
(state) => state.context.position,
// Only notify if x coordinate changes
(prev, next) => prev.x === next.x,
);
XState Store also provides a shallowEqual function that can be used as a default equality function:
import { shallowEqual } from '@xstate/store';
const position = store.select((state) => state.context.position, shallowEqual);
Atoms
An atom is a lightweight, reactive piece of state that can be read, written to, and subscribed to. Atoms can be used standalone or combined with other atoms and stores for more complex state management.
You can:
- Create an atom with
createAtom(initialValue) - Read the atom's value with
atom.get() - Subscribe to changes with
atom.subscribe(observer) - Update the atom with
atom.set(value)
Creating Atoms
Create an atom using createAtom() with an initial value:
import { createAtom } from '@xstate/store';
// Create an atom with a primitive value
const countAtom = createAtom(0);
// Create an atom with an object
const userAtom = createAtom({ name: 'David', count: 100 });
Reading and Writing Atoms
You can read an atom's value using atom.get() and update it using atom.set():
const countAtom = createAtom(0);
// Read the current value
console.log(countAtom.get()); // 0
// Set a new value directly
countAtom.set(1); // 1
// Update value using a function
countAtom.set((prev) => prev + 1); // 2
const count = createAtom(0);
count.get(); // 0
count.set(1); // 1
count.set((prev) => prev + 1); // 2
// Recomputes when count changes
const laugh = createAtom(() => {
return 'ha'.repeat(count.get());
});
laugh.subscribe((value) => {
console.log(value);
});
Subscribing to Changes
Atoms support subscriptions to react to value changes:
const countAtom = createAtom(0);
// Subscribe to changes
const subscription = countAtom.subscribe((newValue) => {
console.log('Count changed:', newValue);
});
countAtom.set(1); // Logs: "Count changed: 1"
// Unsubscribe when done
subscription.unsubscribe();
countAtom.set(2); // Does not log anything
Combined Atoms
You can create derived/computed atoms that combine values from other atoms, stores, or selectors:
const nameAtom = createAtom('David');
const ageAtom = createAtom(30);
// Combine multiple atoms
const userAtom = createAtom(() => ({
name: nameAtom.get(),
age: ageAtom.get(),
}));
// Combined atoms are read-only and update automatically
console.log(userAtom.get()); // { name: 'David', age: 30 }
nameAtom.set('John');
console.log(userAtom.get()); // { name: 'John', age: 30 }
ageAtom.set(31);
console.log(userAtom.get()); // { name: 'John', age: 31 }
Accessing Previous Value in Computed Atoms
Since v3.12.0
Computed atoms can access their previous computed value through the second parameter. This is useful for any derived state that depends on its own previous value:
const countAtom = createAtom(0);
// Running total that aggregates all count changes
// Note: Specify the type parameter for proper inference of prev
const totalAtom = createAtom<number>((_, prev) => countAtom.get() + (prev ?? 0));
console.log(totalAtom.get()); // 0
countAtom.set(5);
console.log(totalAtom.get()); // 5 (0 + 5)
countAtom.set(3);
console.log(totalAtom.get()); // 8 (5 + 3)
countAtom.set(2);
console.log(totalAtom.get()); // 10 (8 + 2)
The previous value is undefined on the first computation. Since TypeScript cannot infer the type of prev, you should provide a type parameter to createAtom<T>() for type safety.
Async Atoms
Since v3.6.0
Async atoms are a special type of atom that handle asynchronous values. They are created using createAsyncAtom(…) and take an async function that returns a promise. The atom's value represents the loading state of the async operation.
The value of an async atom will be an object with a status property that can be:
'pending'- while the promise is resolving'done'with adataproperty containing the resolved value'error'with anerrorproperty containing the error that was thrown
import { createAsyncAtom } from '@xstate/store';
const userAtom = createAsyncAtom(async () => {
const response = await fetch('/api/user');
return response.json();
});
userAtom.subscribe((snapshot) => {
if (snapshot.status === 'pending') {
console.log(snapshot);
// { status: 'pending' }
} else if (snapshot.status === 'done') {
console.log(snapshot);
// { status: 'done', data: { name: 'David', ... } }
} else if (snapshot.status === 'error') {
console.log(snapshot);
// { status: 'error', error: Error('Failed to fetch') }
}
});
Working with Stores and Selectors
Atoms can seamlessly integrate with XState stores and selectors:
const store = createStore({
context: { count: 0 },
on: {
increment: (context) => ({ ...context, count: context.count + 1 }),
},
});
// Create an atom from a store selector
const countSelector = store.select((state) => state.context.count);
const doubleCountAtom = createAtom(() => 2 * countSelector.get());
console.log(doubleCountAtom.get()); // 0
store.trigger.increment();
console.log(doubleCountAtom.get()); // 2
Using Atoms with React
Since v3.7.0
The useAtom hook is the simplest way to use atoms in React. You can use it with or without a selector. The useAtom hook only returns the (selected) value of the atom, since you can set the value of the atom directly using atom.set(…).
import { createAtom } from '@xstate/store';
import { useAtom } from '@xstate/store/react';
const countAtom = createAtom(0);
function Counter() {
// Get the full atom value
const count = useAtom(countAtom);
return (
<div>
<button onClick={() => countAtom.set((prevCount) => prevCount + 1)}>
Increment
</button>
<button onClick={() => countAtom.set(0)}>Reset</button>
<div>Count: {count}</div>
</div>
);
}
// With a selector
const userAtom = createAtom({ name: 'test', age: 25 });
function UserName() {
// Get just the name
const name = useAtom(userAtom, (state) => state.name);
return <div>Name: {name}</div>;
}
You can also provide a custom comparison function to control re-renders:
const userAtom = createAtom({ name: 'David' /* ... */ });
function UserProfile() {
const name = useAtom(
userAtom,
(state) => state.name,
// Custom compare function - case-insensitive comparison
(a, b) => a.toLowerCase() === b.toLowerCase(),
);
return (
<>
<div>Name: {name}</div>
<input
type="text"
value={name}
onChange={(e) =>
userAtom.set((prev) => ({ ...prev, name: e.target.value }))
}
/>
</>
);
}
Using useSelector
useSelectorAlternatively, you can use the useSelector hook, which is similar to useAtom with a selector:
import { createAtom } from '@xstate/store';
import { useSelector } from '@xstate/store/react';
const countAtom = createAtom(0);
function Counter() {
const count = useSelector(countAtom, (s) => s);
return (
<div>
<button onClick={() => countAtom.set((prev) => prev + 1)}>
Increment
</button>
<div>Count: {count}</div>
</div>
);
}
Inspection
Just like with XState, you can use the Inspect API to inspect events sent to the store and state transitions within the store by using the .inspect method:
import { createStore } from '@xstate/store';
const store = createStore({
// ...
});
store.inspect((inspectionEvent) => {
// type: '@xstate.snapshot' or
// type: '@xstate.event'
console.log(inspectionEvent);
});
The .inspect(…) method returns a subscription object:
import { createStore } from '@xstate/store';
const sub = store.inspect((inspectionEvent) => {
console.log(inspectionEvent);
});
// Stop listening for inspection events
sub.unsubscribe();
You can use the Stately Inspector to inspect and visualize the state of the store.
import { createBrowserInspector } from '@statelyai/inspect';
import { createStore } from '@xstate/store';
const store = createStore({
// ...
});
const inspector = createBrowserInspector({
// ...
});
store.inspect(inspector);
Using Immer
You can use the produce(…) function from Immer to update the context in transitions:
import { createStore } from '@xstate/store';
import { produce } from 'immer';
const store = createStore({
context: { count: 0, todos: [] },
on: {
inc: (context, event: { by: number }) =>
produce(context, (draft) => {
draft.count += event.by;
}),
addTodo: (context, event: { todo: string }) =>
produce(context, (draft) => {
draft.todos.push(event.todo);
}),
// Not using a producer
resetCount: (context) => ({
...context,
count: 0,
}),
},
});
Deprecated: createStoreWithProducer(…)
In previous versions of @xstate/store, you could use the createStoreWithProducer(…) function to pass in a producer function to update the context for every transition. This will not be supported in future versions of @xstate/store. Instead, you can use the produce(…) function from Immer or similar libraries directly with createStore(…).
import { createStore } from '@xstate/store';
import { produce } from 'immer';
// Deprecated API
const store = createStoreWithProducer(produce, {
context: { count: 0, todos: [] },
on: {
inc: (context, event: { by: number }) => {
// No return; handled by Immer
context.count += event.by;
},
addTodo: (context, event: { todo: string }) => {
// No return; handled by Immer
context.todos.push(event.todo);
},
},
});
// ...
Usage with React
If you are using React, you can use the useSelector(store, selector) hook to subscribe to the store and get the current state.
import { createStore } from '@xstate/store';
import { useSelector } from '@xstate/store/react';
// Create a store
const store = createStore({
context: { count: 0, name: 'David' },
on: {
inc: (context) => ({
...context,
count: context.count + 1,
}),
},
});
// Use the `useSelector` hook to subscribe to the store
function Component(props) {
const count = useSelector(store, (state) => state.context.count);
// This component displays the count and has a button to increment it
return (
<div>
<button onClick={() => store.trigger.inc()}>Increment</button>
</div>
);
}
A store can be shared with multiple components, which will all receive the same snapshot from the store instance. Stores are useful for global state management.
Custom store hooks with createStoreHook()
Since v3.9.0
The createStoreHook() function creates a custom React hook that combines the functionality of useStore() and useSelector() into a single, convenient hook. This is useful when you want to create reusable store logic that can be easily shared across components.
import { createStoreHook } from '@xstate/store/react';
const useCountStore = createStoreHook({
context: { count: 0 },
on: {
inc: (ctx, event: { by: number }) => ({
...ctx,
count: ctx.count + event.by,
}),
},
});
// Usage with selector
function Counter() {
const [count, store] = useCountStore((s) => s.context.count);
return (
<div>
<div>Count: {count}</div>
<button onClick={() => store.trigger.inc({ by: 1 })}>
Increment by 1
</button>
<button onClick={() => store.trigger.inc({ by: 3 })}>
Increment by 3
</button>
</div>
);
}
// Usage without selector (get entire snapshot)
function CounterWithFullState() {
const [snapshot, store] = useCountStore();
return (
<div>
<div>Count: {snapshot.context.count}</div>
<button onClick={() => store.trigger.inc({ by: 1 })}>Increment</button>
</div>
);
}
The resulting hook from createStoreHook() returns a [state, store] tuple:
state: The selected state (when using a selector) or the entire snapshot (when no selector is provided)store: The store instance with all its methods, includingstore.triggerfor sending events
You can also provide a custom comparator function as the third argument to optimize re-renders:
function OptimizedCounter() {
const [count, store] = useCountStore(
(s) => s.context.count,
// Custom comparator function
(prev, next) => prev === next, // Only re-render when count actually changes
);
return (
<div>
<div>Count: {count}</div>
<button onClick={() => store.trigger.inc({ by: 1 })}>Increment</button>
</div>
);
}
Local Stores with useStore()
The useStore() hook allows you to create local stores within React components, similar to useReducer(). This is useful when you want to manage component-specific state that doesn't need to be shared globally:
import { useStore, useSelector } from '@xstate/store/react';
function Counter({ initialCount = 0 }) {
const store = useStore({
context: {
count: initialCount,
},
emits: {
increased: (payload: { by: number }) => {},
},
on: {
increment: (context, event: { by: number }, enqueue) => {
enqueue.emit.increased({ by: event.by });
return { ...context, count: context.count + event.by };
},
},
});
const count = useSelector(store, (state) => state.context.count);
return (
<div>
<div>Count: {count}</div>
<button onClick={() => store.trigger.increment({ by: 1 })}>
Increment by 1
</button>
<button onClick={() => store.trigger.increment({ by: 5 })}>
Increment by 5
</button>
</div>
);
}
The store created by useStore() has all the same capabilities as a global store:
- Send events using
store.triggerorstore.send() - Select state using
useSelector() - Listen to emitted events
- Use effects and side effects
- Use selectors
Listening to Emitted Events
You can listen to events emitted by the local store via useEffect(…):
function Counter({
initialCount = 0,
onIncreased,
}: {
initialCount?: number;
onIncreased?: (by: number) => void;
}) {
const store = useStore({
// ... store config
});
// Listen to emitted events
useEffect(() => {
const subscription = store.on('increased', ({ by }) => {
onIncreased?.(by);
});
return subscription.unsubscribe;
}, [store, onIncreased]);
// ... rest of component
}
Initializing with Props
Local stores can be initialized using component props, making them more reusable:
function Counter({ initialCount = 0, step = 1 }) {
const store = useStore({
context: {
count: initialCount,
step,
},
on: {
increment: (context) => ({
...context,
count: context.count + context.step,
}),
},
});
// ... rest of component
}
Props and Store State
Similar to useState and useReducer, changes to props after the initial render will not automatically update the store's state. The store's state can only be updated by sending events:
function Counter({ currentCount = 0 }) {
const store = useStore({
context: {
// currentCount is only used once during initialization
count: currentCount,
},
on: {
countUpdated: (context, event: { value: number }) => ({
...context,
count: event.value,
}),
},
});
// If you need to update the store when props change,
// you'll need to send an event explicitly:
useEffect(() => {
store.trigger.countUpdated({ value: currentCount });
}, [store, currentCount]);
// ... rest of component
}
This behavior ensures that state updates are always explicit and traceable through events, maintaining a predictable data flow in your application.
Usage with Solid
Documentation coming soon!
Undo/Redo
Since v3.14.0
XState Store provides built-in undo/redo functionality through the undoRedo store extension. You can choose between two strategies:
- Event-sourced (default): Stores events in history and replays them during undo/redo. Memory efficient and provides precise control over transaction grouping and event skipping while properly replaying side effects.
- Snapshot: Stores full state snapshots in history. Useful when you need to preserve state that might be lost during event replay, or when you want to limit history size.
Basic Usage
Import undoRedo from @xstate/store/undo and use it with the .with() method. The store will have undo() and redo() methods available on store.trigger:
import { createStore } from '@xstate/store';
import { undoRedo } from '@xstate/store/undo';
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo());
You can undo and redo multiple events in sequence. Each undo() call moves back one event in history, and each redo() call moves forward one event.
Example: Basic undo/redo
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo());
// Use the store normally
store.trigger.inc(); // count = 1
store.trigger.inc(); // count = 2
// Undo the last event
store.trigger.undo(); // count = 1
// Redo the undone event
store.trigger.redo(); // count = 2
Example: Multiple undo/redo operations
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo());
store.trigger.inc(); // count = 1
store.trigger.inc(); // count = 2
store.trigger.inc(); // count = 3
store.trigger.undo(); // count = 2
store.trigger.undo(); // count = 1
store.trigger.redo(); // count = 2
store.trigger.redo(); // count = 3
Transactions
You can group multiple events into transactions so they are undone/redone together using getTransactionId. Events with the same transaction ID are grouped together and undone/redone as a single unit:
const store = createStore({
// ...
}).with(undoRedo({
getTransactionId: (event, snapshot) => {
// Return a transaction ID to group events
// Events with the same ID are grouped together
},
}));
Example: Group events by type
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
dec: (context) => ({ count: context.count - 1 }),
},
}).with(undoRedo({
// Group events by their type
getTransactionId: (event) => event.type,
}));
// First transaction: increments
store.trigger.inc(); // count = 1
store.trigger.inc(); // count = 2
// Second transaction: decrements
store.trigger.dec(); // count = 1
store.trigger.dec(); // count = 0
// Undo second transaction (both decrements)
store.trigger.undo(); // count = 2
// Undo first transaction (both increments)
store.trigger.undo(); // count = 0
Example: Group events by context state
const store = createStore({
context: { count: 0, transactionId: null as string | null },
on: {
inc: (context) => ({ ...context, count: context.count + 1 }),
setTransactionId: (context, event: { id: string }) => ({
...context,
transactionId: event.id,
}),
},
}).with(undoRedo({
// Use context state to determine transaction grouping
getTransactionId: (event, snapshot) => snapshot.context.transactionId,
}));
store.trigger.inc(); // count = 1
store.trigger.setTransactionId({ id: 'batch-1' });
store.trigger.inc(); // count = 2
store.trigger.inc(); // count = 3
store.trigger.inc(); // count = 4
store.trigger.setTransactionId({ id: 'batch-2' });
store.trigger.inc(); // count = 5
store.trigger.inc(); // count = 6
// Undo second transaction (all events with batch-2)
store.trigger.undo(); // count = 4
// Undo first transaction (all events with batch-1)
store.trigger.undo(); // count = 1
Skipping Events
You can configure certain events to be skipped from the undo/redo history using skipEvent. Skipped events are not stored in history and are not replayed during undo/redo operations:
const store = createStore({
// ...
}).with(undoRedo({
skipEvent: (event, snapshot) => {
// Return true to skip this event from history
// Return false to include it in history
},
}));
Example: Skip specific event types
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
log: (context) => context, // No state change, just logging
},
}).with(undoRedo({
// Skip log events from undo/redo history
skipEvent: (event) => event.type === 'log',
}));
store.trigger.inc(); // count = 1
store.trigger.log(); // count = 1 (logged but not undoable)
store.trigger.inc(); // count = 2
// Undo skips log event, only undoes inc events
store.trigger.undo(); // count = 1
Example: Skip events based on state
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo({
// Skip events when count is 3 or more
skipEvent: (event, snapshot) => snapshot.context.count >= 3,
}));
store.trigger.inc(); // count = 1
store.trigger.inc(); // count = 2
store.trigger.inc(); // count = 3
store.trigger.inc(); // count = 4 (skipped from history)
store.trigger.undo(); // count = 2 (skips the last inc)
Effects and Emitted Events
Emitted events are properly replayed during undo/redo operations. When you undo or redo an event, any events that were emitted during the original transition are re-emitted:
const store = createStore({
// ...
emits: {
// ...
},
on: {
// ...
},
}).with(undoRedo());
Skipped events do not have their emitted events replayed during undo/redo operations.
Example: Emitted events are replayed
const store = createStore({
context: { count: 0 },
emits: {
changed: (payload: { value: number }) => {},
},
on: {
inc: (context, event, enqueue) => {
enqueue.emit.changed({ value: context.count + 1 });
return { count: context.count + 1 };
},
},
}).with(undoRedo());
const emittedEvents: Array<{ type: string; value: number }> = [];
store.on('changed', (event) => {
emittedEvents.push(event);
});
store.trigger.inc(); // emits changed(1)
store.trigger.undo(); // emits changed(0)
store.trigger.redo(); // emits changed(1)
// emittedEvents = [
// { type: 'changed', value: 1 },
// { type: 'changed', value: 0 },
// { type: 'changed', value: 1 }
// ]
Example: Skipped events don't replay emitted events
const store = createStore({
context: { count: 0 },
emits: {
changed: (payload: { value: number }) => {},
logged: (payload: { message: string }) => {},
},
on: {
inc: (context, event, enqueue) => {
enqueue.emit.changed({ value: context.count + 1 });
return { count: context.count + 1 };
},
log: (context, event: { message: string }, enqueue) => {
enqueue.emit.logged({ message: event.message });
return context; // No state change
},
},
}).with(undoRedo({
skipEvent: (event) => event.type === 'log',
}));
const emittedEvents: any[] = [];
store.on('changed', (event) => emittedEvents.push(event));
store.on('logged', (event) => emittedEvents.push(event));
store.trigger.inc(); // emits changed(1)
store.trigger.log({ message: 'test' }); // emits logged('test') but not stored in history
store.trigger.inc(); // emits changed(2)
emittedEvents.length = 0;
store.trigger.undo(); // emits changed(1)
store.trigger.undo(); // emits changed(0)
store.trigger.redo(); // emits changed(1)
store.trigger.redo(); // emits changed(2)
// Only inc events are emitted during undo/redo, log events are skipped
// emittedEvents = [
// { type: 'changed', value: 1 },
// { type: 'changed', value: 0 },
// { type: 'changed', value: 1 },
// { type: 'changed', value: 2 }
// ]
Interleaving Undo/Redo with New Events
When you send a new event after undoing, the redo stack is cleared. This ensures that the history remains consistent and prevents unexpected behavior when mixing undo/redo with new events.
Example: New events clear redo stack
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
dec: (context) => ({ count: context.count - 1 }),
},
}).with(undoRedo());
store.trigger.inc(); // count = 1
store.trigger.inc(); // count = 2
store.trigger.undo(); // count = 1
store.trigger.dec(); // count = 0 (clears redo stack)
// Redo should not work as we added a new event after undo
store.trigger.redo(); // count = 0 (no change)
Empty Operations
Undo/redo operations do nothing when there's no history to undo/redo. Calling undo() when there's no history, or redo() when there's nothing to redo, will have no effect on the store state.
Example: Empty operations
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo());
const initialSnapshot = store.getSnapshot();
// Undo with empty history does nothing
store.trigger.undo();
expect(store.getSnapshot()).toEqual(initialSnapshot);
// Redo with empty redo stack does nothing
store.trigger.redo();
expect(store.getSnapshot()).toEqual(initialSnapshot);
Snapshot Strategy
By default, undoRedo uses the event-sourced strategy. You can switch to the snapshot strategy by setting strategy: 'snapshot'. The snapshot strategy stores full state snapshots in history, which can be useful when:
- You need to preserve state that might be lost during event replay
- You want to limit history size with
historyLimit - You want to skip duplicate snapshots with a
comparefunction
const store = createStore({
// ...
}).with(undoRedo({ strategy: 'snapshot' }));
Example: Using snapshot strategy
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo({ strategy: 'snapshot' }));
store.trigger.inc(); // count = 1
store.trigger.inc(); // count = 2
store.trigger.undo(); // count = 1
store.trigger.redo(); // count = 2
History Limit
With the snapshot strategy, you can limit the number of snapshots stored in history using historyLimit. When the limit is reached, older snapshots are removed from history:
const store = createStore({
// ...
}).with(undoRedo({
strategy: 'snapshot',
historyLimit: 2,
}));
Example: History limit
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo({
strategy: 'snapshot',
historyLimit: 2,
}));
store.trigger.inc(); // count = 1
store.trigger.inc(); // count = 2
store.trigger.inc(); // count = 3
store.trigger.inc(); // count = 4
// Can only undo 2 times because of history limit
store.trigger.undo(); // count = 3
store.trigger.undo(); // count = 2
store.trigger.undo(); // count = 2 (limit reached, no change)
Compare Function
With the snapshot strategy, you can provide a compare function to skip duplicate snapshots. If the compare function returns true for two consecutive snapshots, the second snapshot is not saved in history:
const store = createStore({
// ...
}).with(undoRedo({
strategy: 'snapshot',
compare: (past, current) => {
// Return true to skip saving this snapshot (duplicate)
// Return false to save this snapshot
},
}));
Example: Skip duplicate snapshots
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
noop: (context) => context, // No state change
},
}).with(undoRedo({
strategy: 'snapshot',
// Skip snapshots where count hasn't changed
compare: (past, current) => past.context.count === current.context.count,
}));
store.trigger.inc(); // count = 1 (saved)
store.trigger.noop(); // count = 1 (duplicate, not saved)
store.trigger.noop(); // count = 1 (duplicate, not saved)
store.trigger.inc(); // count = 2 (saved)
// Should only have 2 snapshots in history (0 and 1), not 4
store.trigger.undo(); // count = 1
store.trigger.undo(); // count = 0
store.trigger.undo(); // count = 0 (no change)
Example: All snapshots saved without compare function
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
noop: (context) => context,
},
}).with(undoRedo({ strategy: 'snapshot' }));
store.trigger.inc(); // count = 1 (saved)
store.trigger.noop(); // count = 1 (saved even though duplicate)
store.trigger.noop(); // count = 1 (saved even though duplicate)
store.trigger.inc(); // count = 2 (saved)
// Should have 4 snapshots in history (0, 1, 1, 1)
store.trigger.undo(); // count = 1
store.trigger.undo(); // count = 1
store.trigger.undo(); // count = 1
store.trigger.undo(); // count = 0
Preserving State with Skipped Events
With the snapshot strategy, skipped events preserve their state in snapshots. This is because snapshots capture the full state at a point in time, including changes made by skipped events:
const store = createStore({
// ...
}).with(undoRedo({
strategy: 'snapshot',
skipEvent: (event) => {
// ...
},
}));
Example: Preserving state with skipped events
const store = createStore({
context: { count: 0, logs: [] as string[] },
on: {
inc: (context) => ({ count: context.count + 1, logs: context.logs }),
log: (context, event: { type: 'log'; message: string }) => ({
logs: [...context.logs, event.message],
count: context.count,
}),
},
}).with(undoRedo({
strategy: 'snapshot',
skipEvent: (event) => event.type === 'log',
}));
store.trigger.inc(); // count = 1, logs = []
store.trigger.log({ message: 'first log' }); // count = 1, logs = ['first log'] (not tracked)
store.trigger.inc(); // count = 2, logs = ['first log']
// Undo should restore snapshot before second inc, which includes the log
store.trigger.undo(); // count = 1, logs = ['first log']
Configuration Options
The undoRedo function accepts a configuration object with these options:
strategy:'event-sourced'(default) or'snapshot'- The strategy to use for historygetTransactionId(event, snapshot): Function to determine transaction groupingskipEvent(event, snapshot): Function to determine if an event should be skippedhistoryLimit(snapshot strategy only): Maximum number of snapshots to store in historycompare(snapshot strategy only): Function to compare snapshots and skip duplicates
Type Safety
The undo/redo extension preserves all TypeScript types from your original store:
const store = createStore({
context: { count: 0 },
on: {
inc: (context) => ({ count: context.count + 1 }),
},
}).with(undoRedo());
// Type safety is preserved
store.getSnapshot().context satisfies { count: number };
// @ts-expect-error
store.getSnapshot().context.foo;
// @ts-expect-error
store.trigger.dec();
Notes
- Event-sourced by default: The default strategy stores events, not snapshots, making it memory efficient
- New events clear redo stack: When you send a new event after undoing, the redo stack is cleared
- Empty operations: Undo/redo operations do nothing when there's no history to undo/redo
- Type safety: The undo/redo extension preserves all TypeScript types from your original store
- Emitted events: Events emitted during transitions are replayed during undo/redo operations
Using XState Store with XState
You may notice that stores are very similar to actors in XState. This is very much by design. XState's actors are very powerful, but may also be too complex for simple use cases, which is why @xstate/store exists.
However, if you have existing XState code, and you enjoy the simplicity of creating store logic with @xstate/store, you can use the fromStore(context, transitions) actor logic creator to create XState-compatible store logic that can be passed to the createActor(storeLogic) function:
import { fromStore } from '@xstate/store';
import { createActor } from 'xstate';
// Instead of:
// const store = createStore( ... };
const storeLogic = fromStore({
context: { count: 0, incremented: false /* ... */ },
on: {
inc: {
count: (context, event) => context.count + 1,
// Static values do not need to be wrapped in a function
incremented: true,
},
},
});
const store = createActor(storeLogic);
store.subscribe((snapshot) => {
console.log(snapshot);
});
store.start();
store.send({
type: 'inc',
});
In short, you can convert createStore(…) to fromStore(…) just by changing one line of code. Note that fromStore(…) returns store logic, and not a store actor instance. Store logic is passed to createActor(storeLogic) to create a store actor instance:
import { fromStore } from '@xstate/store';
// Instead of:
// const store = createStore({
const storeLogic = fromStore({
context: {
// ...
},
on: {
// ...
},
});
// Create the store (actor)
const storeActor = createActor(storeLogic);
Using fromStore(…) to create store actor logic also has the advantage of allowing you to provide input by using a context function that takes in the input and returns the initial context:
import { fromStore } from '@xstate/store';
const storeLogic = fromStore({
context: (initialCount: number) => ({
count: initialCount,
}),
on: {
// ...
},
});
const actor = createActor(storeLogic, {
input: 42,
});
Converting stores to state machines
If you have a store that you want to convert to a state machine in XState, you can convert it in a straightforward way:
- Use
createMachine(…)(imported fromxstate) instead ofcreateStore(…)(imported from@xstate/store) to create a state machine. - Wrap the assignments in an
assign(…)action creator (imported fromxstate) and move that to theactionsproperty of the transition. - Destructure
contextandeventfrom the first argument instead of them being separate arguments.
For example, here is our store before conversion:
import { createMachine } from 'xstate';
// 1. Use `createMachine(…)` instead of `createStore(…)`
const store = createStore({
context: { count: 0, name: 'David' },
on: {
inc: {
// 2. Wrap the assignments in `assign(…)`
count: (context, event: { by: number }) => context.count + event.by,
},
},
});
const machine = createMachine({
// ...
});
And here is the store as a state machine after conversion:
import { createMachine } from 'xstate';
// const store = createStore({
// context: { count: 0, name: 'David' },
// on: {
// inc: {
// count: (context, event: { by: number }) => context.count + event.by
// }
// }
// });
// 1. Use `createMachine(…)` instead of `createStore(…)`
const machine = createMachine({
context: {
count: 0,
name: 'David',
},
on: {
inc: {
// 2. Wrap the assignments in `assign(…)`
actions: assign({
// 3. Destructure `context` and `event` from the first argument
count: ({ context, event }) => context.count + event.by,
}),
},
},
});
For stronger typing, use the setup(…) function to strongly type the context and events:
import { setup } from 'xstate';
const machine = setup({
types: {
context: {} as { count: number; name: string },
events: {} as { type: 'inc'; by: number },
},
}).createMachine({
// Same as the previous example
});
Comparison
This section compares XState Store to other popular state management libraries in TypeScript. It is meant for reference purposes only, and not intended to favor one approach over the other. The examples are copied from Zustand's comparison docs.
Compare to Zustand
Zustand
import { create } from 'zustand';
type State = {
count: number;
};
type Actions = {
increment: (qty: number) => void;
decrement: (qty: number) => void;
};
const useCountStore = create<State & Actions>((set) => ({
count: 0,
increment: (qty: number) =>
set((state) => ({
count: state.count + qty,
})),
decrement: (qty: number) =>
set((state) => ({
count: state.count - qty,
})),
}));
const Component = () => {
const count = useCountStore((state) => state.count);
const increment = useCountStore((state) => state.increment);
const decrement = useCountStore((state) => state.decrement);
// ...
};
XState Store
import { createStore } from '@xstate/store';
import { useSelector } from '@xstate/store/react';
const store = createStore({
context: {
count: 0,
},
on: {
increment: (context, { qty }: { qty: number }) => ({
...context,
count: context.count + qty,
}),
decrement: (context, { qty }: { qty: number }) => ({
...context,
count: context.count - qty,
}),
},
});
const Component = () => {
const count = useSelector(store, (state) => state.context.count);
const { increment, decrement } = store.trigger;
// ...
};