Skip to content

Commit f3213af

Browse files
committed
Add XState Store v3 post
1 parent 8adc33e commit f3213af

File tree

2 files changed

+500
-0
lines changed

2 files changed

+500
-0
lines changed
+300
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
---
2+
title: 'XState Store v3'
3+
description: 'XState Store v3 brings improved state management capabilities with better TypeScript support and a more streamlined API.'
4+
tags: [xstate, store, state management, typescript]
5+
authors: [david]
6+
image: /blog/2025-02-26-xstate-store-v3.png
7+
slug: 2025-02-26-xstate-store-v3
8+
date: 2025-02-26
9+
---
10+
11+
We're excited to announce the release of XState Store v3! This new version brings improved state management capabilities with better TypeScript support and a more streamlined API.
12+
13+
{/* truncate */}
14+
15+
The main motivation for this new version is to make the store more ergonomic and easier to use. Some key improvements include:
16+
17+
- **Simplified Context Updates**: Only one way to update context is now supported - using complete context assigner functions. This removes confusion around partial updates and makes the behavior more predictable. It's even now possible to use _typestates_ to ensure context updates are always complete and valid.
18+
19+
- **Enhanced TypeScript Experience**: The new `store.trigger.someEvent(...)` API provides better TypeScript autocompletion for events, making it easier to discover and use available events with proper typing.
20+
21+
- **Cleaner Event Emission**: The new `emits: { ... }` configuration replaces the more awkward `types: { emit: {} as ... }` syntax, making it more intuitive to define emitted events. You can even provide default side effects for these events.
22+
23+
- **Structured Side Effects**: Introduction of `enq.effect()` provides a "blessed" way to handle side effects, ensuring state transitions remain pure while making effects trackable and testable.
24+
25+
- **Store Selectors**: New selector API allows for efficient state subscriptions with fine-grained control over updates, preventing unnecessary re-renders and simplifying state access.
26+
27+
## Breaking Changes
28+
29+
The breaking changes in `@xstate/store` v3 include:
30+
31+
- The `createStore(config)` function now only accepts a _single_ configuration object
32+
- Only complete context assigner functions are now supported
33+
- The `config.types` property has been removed
34+
35+
```ts
36+
import { createStore } from '@xstate/store';
37+
38+
const store = createStore({
39+
context: { count: 0 },
40+
on: {
41+
increment: (context, event: { by: number }) => ({
42+
...context,
43+
count: context.count + event.by,
44+
}),
45+
},
46+
});
47+
48+
// Sending an event object:
49+
store.send({ type: 'increment', by: 5 });
50+
51+
// Triggering an event (equivalent to the above):
52+
store.trigger.increment({ by: 5 });
53+
```
54+
55+
## Triggering Events
56+
57+
The `store.trigger` API is a more ergonomic way to send events to the store:
58+
59+
```ts
60+
import { createStore } from '@xstate/store';
61+
62+
const store = createStore({
63+
context: { count: 0 },
64+
on: {
65+
increment: (context, event: { by: number }) => ({
66+
...context,
67+
count: context.count + event.by,
68+
}),
69+
},
70+
});
71+
72+
// Sending an event object:
73+
store.send({ type: 'increment', by: 5 });
74+
75+
// highlight-start
76+
// Triggering an event:
77+
store.trigger.increment({ by: 5 });
78+
// highlight-end
79+
```
80+
81+
While you can still use `store.send(…)` to send events, the `store.trigger` API is more ergonomic, since it provides for immediate autocompletion of event types.
82+
83+
## Handling Effects
84+
85+
You can now enqueue effects in state transitions:
86+
87+
```ts
88+
import { createStore } from '@xstate/store';
89+
90+
const store = createStore({
91+
context: {
92+
count: 0,
93+
},
94+
on: {
95+
incrementDelayed: (context, event, enq) => {
96+
enq.effect(async () => {
97+
await new Promise((resolve) => setTimeout(resolve, 1000));
98+
store.send({ type: 'increment' });
99+
});
100+
return context;
101+
},
102+
increment: (context) => ({ ...context, count: context.count + 1 }),
103+
},
104+
});
105+
```
106+
107+
You might be wondering why we use `enq.effect(…)` instead of directly executing side effects in the transition. The answer is simple: **state transitions must be pure functions**. This makes it possible to compute the next state and its effects without actually executing those effects, which will be available in a [future `store.transition(state, event)` API](https://github.com/statelyai/xstate/pull/5215).
108+
109+
Internally, XState Store v3 computes a tuple of the next state and the effects to be executed `const [nextState, effects] = store.transition(state, event)`. Then, it notifies all observers with the next state, and executes the effects in the background.
110+
111+
<details>
112+
<summary>Here's an example showing the difference:</summary>
113+
114+
```ts
115+
// ❌ Impure store - side effects mixed with state updates
116+
const impureStore = createStore({
117+
context: { count: 0 },
118+
on: {
119+
incrementDelayed: (context) => {
120+
// Bad: This directly executes side effects in the transition
121+
setTimeout(() => {
122+
impureStore.send({ type: 'increment' });
123+
}, 1000);
124+
return context;
125+
},
126+
increment: (context) => ({
127+
...context,
128+
count: context.count + 1,
129+
}),
130+
},
131+
});
132+
133+
// ✅ Pure store - effects are declared separately
134+
const pureStore = createStore({
135+
context: { count: 0 },
136+
on: {
137+
incrementDelayed: (context, _event, enq) => {
138+
// Good: Effects are declared separately and handled by the store
139+
enq.effect(async () => {
140+
await new Promise((resolve) => setTimeout(resolve, 1000));
141+
pureStore.send({ type: 'increment' });
142+
});
143+
return context;
144+
},
145+
increment: (context) => ({
146+
...context,
147+
count: context.count + 1,
148+
}),
149+
},
150+
});
151+
```
152+
153+
</details>
154+
155+
## Emitting Events
156+
157+
XState Store v3 introduces a structured way to define and emit events:
158+
159+
```typescript
160+
const store = createStore({
161+
context: { count: 0 },
162+
emits: {
163+
increased: (payload: { upBy: number }) => {
164+
// Optional side effects can go here
165+
},
166+
},
167+
on: {
168+
increment: (ctx, ev: { by: number }, enq) => {
169+
enq.emit.increased({ upBy: ev.by });
170+
return { ...ctx, count: ctx.count + ev.by };
171+
},
172+
},
173+
});
174+
```
175+
176+
This replaces the previous `types: { … }` configuration.
177+
178+
## Selectors
179+
180+
XState Store v3 introduces selectors that enable efficient state selection and subscription. Selectors allow you to:
181+
182+
- Get the current value of a specific part of the state
183+
- Subscribe to changes in that specific part of the state
184+
- Only receive updates when the selected value actually changes
185+
- Control when updates happen with custom equality functions
186+
187+
Here's how to use selectors:
188+
189+
```ts
190+
import { createStore } from '@xstate/store';
191+
192+
const store = createStore({
193+
context: {
194+
position: { x: 0, y: 0 },
195+
name: 'John',
196+
age: 30,
197+
},
198+
on: {
199+
positionUpdated: (
200+
context,
201+
event: { position: { x: number; y: number } },
202+
) => ({
203+
...context,
204+
position: event.position,
205+
}),
206+
},
207+
});
208+
209+
// Create a selector for the position
210+
const position = store.select((state) => state.context.position);
211+
212+
// Get the current position
213+
position.get(); // { x: 0, y: 0 }
214+
215+
// Subscribe to position changes
216+
position.subscribe((position) => {
217+
console.log('Position updated:', position);
218+
});
219+
220+
// Update the position
221+
store.trigger.positionUpdated({ position: { x: 100, y: 200 } });
222+
// Logs: Position updated: { x: 100, y: 200 }
223+
```
224+
225+
You can also provide a custom equality function to control when subscribers are notified:
226+
227+
```ts
228+
import { shallowEqual } from '@xstate/store';
229+
230+
// Only notify when position changes (shallow equality)
231+
const position = store.select((state) => state.context.position, shallowEqual);
232+
```
233+
234+
This is particularly useful when selecting objects or arrays where you want to prevent unnecessary updates.
235+
236+
## The `useStore()` Hook
237+
238+
XState Store v3 introduces a new `useStore()` hook that allows you create a **local store** in your React components:
239+
240+
```tsx
241+
import { useStore, useSelector } from '@xstate/store/react';
242+
243+
function Counter(props: { initialCount?: number }) {
244+
const store = useStore({
245+
context: {
246+
count: props.initialCount ?? 0,
247+
},
248+
emits: {
249+
increased: (payload: { upBy: number }) => {},
250+
},
251+
on: {
252+
inc: (ctx, { by }: { by: number }, enq) => {
253+
enq.emit.increased({ upBy: by });
254+
return { ...ctx, count: ctx.count + by };
255+
},
256+
},
257+
});
258+
const count = useSelector(store, (state) => state.count);
259+
260+
return (
261+
<div>
262+
<div>Count: {count}</div>
263+
<button onClick={() => store.trigger.inc({ by: 1 })}>
264+
Increment by 1
265+
</button>
266+
<button onClick={() => store.trigger.inc({ by: 5 })}>
267+
Increment by 5
268+
</button>
269+
</div>
270+
);
271+
}
272+
```
273+
274+
The `useStore()` hook is a React hook that returns a store instance. You can:
275+
276+
- send events to the store via `store.trigger.inc({ by: 1 })` or `store.send({ type: 'inc', by: 1 })`
277+
- select state via `useSelector(store, (state) => state.count)`
278+
- listen to emitted events via `useEffect(…, [store])` to react to them:
279+
280+
```tsx
281+
//
282+
useEffect(() => {
283+
const sub = store.on('increased', ({ upBy }) => {
284+
console.log(`Count increased by ${upBy}`);
285+
});
286+
287+
return sub.unsubscribe;
288+
}, [store]);
289+
//
290+
```
291+
292+
## What's next?
293+
294+
We want `@xstate/store` to remain a small, simple, and focused library for state management. A few features were added to this version, but we still aim to keep the API surface area small.
295+
296+
If you've used Zustand, Redux, Pinia, or XState, you'll find `@xstate/store` very familiar. Please keep in mind that you should choose the state management library that best suits your requirements and your team's preferences. However, it is straightforward to migrate to `@xstate/store` from Redux, Zustand, Pinia, XState, or other state management libraries if needed (and vice versa).
297+
298+
Our goal with `@xstate/store` is to provide a simple yet powerful _event-based_ state management solution that is type-safe. We believe that indirect (event-based) state management leads to better organization of application logic, especially as it grows in complexity, and `@xstate/store` is a great starting point for that approach.
299+
300+
Give it a try, and feel free to ask any questions in [our Discord](https://discord.gg/xstate) or report bugs in [the XState GitHub repo](https://github.com/statelyai/xstate/issues). We're always looking for feedback on how we can improve the experience!

0 commit comments

Comments
 (0)