Skip to content

Commit

Permalink
[@xstate/store] v3 (#5175)
Browse files Browse the repository at this point in the history
* Only complete assigners are allowed

* `createStore` only takes a single { context, on } argument now

* createStoreWithProducer

* Enhance store functionality by introducing effects in event handling

- Added `StoreEffect` type to support both emitted events and side effects.
- Updated `createStoreTransition` to return effects instead of emitted events.
- Modified `receive` function to handle effects, executing functions or emitting events accordingly.
- Added a test case to verify that effects can be enqueued and executed after state updates.

This change improves the flexibility of the store's event handling mechanism.

* use overload trick 🫠

* Fix fromStore

* Update changesets

* Add support for type parameters

* Revert "Add support for type parameters"

This reverts commit cfe28e6.

* Refactor store type parameters to improve type safety and flexibility

- Updated `createStore` and `createStoreWithProducer` to use more explicit type parameters
- Replaced `types: { emitted }` with separate type parameters for context, event payloads, and emitted events
- Removed `Cast` import and simplified type definitions
- Updated test cases to use new type parameter approach
- Added `EventMap` type to support event type mapping

* Add trigger + test + changeset

* Add emits

* Fixed TS issue

* use correct types

* Remove changeset

* use util

* remove redundant test

* tweak things

* remove commented out code

* Quick typestates test

* Update tests

* Update fromStore

* Fix tests

* Update packages/xstate-store/test/fromStore.test.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Improve jsdoc comments and test

---------

Co-authored-by: Mateusz Burzyński <[email protected]>
  • Loading branch information
davidkpiano and Andarist authored Feb 9, 2025
1 parent 53b2c8e commit 38aa9f5
Show file tree
Hide file tree
Showing 18 changed files with 763 additions and 589 deletions.
27 changes: 27 additions & 0 deletions .changeset/five-walls-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@xstate/store': major
---

The `createStore` function now only accepts a single configuration object argument. This is a breaking change that simplifies the API and aligns with the configuration pattern used throughout XState.

```ts
// Before
// createStore(
// {
// count: 0
// },
// {
// increment: (context) => ({ count: context.count + 1 })
// }
// );

// After
createStore({
context: {
count: 0
},
on: {
increment: (context) => ({ count: context.count + 1 })
}
});
```
24 changes: 24 additions & 0 deletions .changeset/great-candles-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@xstate/store': major
---

You can now enqueue effects in state transitions.

```ts
const store = createStore({
context: {
count: 0
},
on: {
incrementDelayed: (context, event, enq) => {
enq.effect(async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
store.send({ type: 'increment' });
});

return context;
},
increment: (context) => ({ count: context.count + 1 })
}
});
```
17 changes: 17 additions & 0 deletions .changeset/mean-taxis-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@xstate/store': major
---

The `fromStore(config)` function now only supports a single config object argument.

```ts
const storeLogic = fromStore({
context: (input: { initialCount: number }) => ({ count: input.initialCount }),
on: {
inc: (ctx, ev: { by: number }) => ({
...ctx,
count: ctx.count + ev.by
})
}
});
```
24 changes: 24 additions & 0 deletions .changeset/quick-bears-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@xstate/store': minor
---

Added `store.trigger` API for sending events with a fluent interface:

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

// Instead of manually constructing event objects:
store.send({ type: 'increment', by: 5 });

// You can now use the fluent trigger API:
store.trigger.increment({ by: 5 });
```

The `trigger` API provides full type safety for event names and payloads, making it easier and safer to send events to the store.
32 changes: 32 additions & 0 deletions .changeset/spotty-moose-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@xstate/store': major
---

The `createStoreWithProducer(…)` function now only accepts two arguments: a `producer` and a config (`{ context, on }`) object.

```ts
// Before
// createStoreWithProducer(
// producer,
// {
// count: 0
// },
// {
// increment: (context) => {
// context.count++;
// }
// }
// );

// After
createStoreWithProducer(producer, {
context: {
count: 0
},
on: {
increment: (context) => {
context.count++;
}
}
});
```
19 changes: 19 additions & 0 deletions .changeset/thick-paws-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@xstate/store': major
---

Only complete assigner functions that replace the `context` fully are supported. This is a breaking change that simplifies the API and provides more type safety.

```diff
const store = createStore({
context: {
items: [],
count: 0
},
on: {
- increment: { count: (context) => context.count + 1 }
- increment: (context) => ({ count: context.count + 1 })
+ increment: (context) => ({ ...context, count: context.count + 1 })
}
})
```
24 changes: 24 additions & 0 deletions .changeset/wise-bikes-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@xstate/store': major
---

Emitted event types are now specified in functions on the `emits` property of the store definition:

```ts
const store = createStore({
//
emits: {
increased: (payload: { upBy: number }) => {
// You can execute a side-effect here
// or leave it empty
}
},
on: {
inc: (ctx, ev: { by: number }, enq) => {
enq.emit.increased({ upBy: ev.by });

//
}
}
});
```
140 changes: 34 additions & 106 deletions packages/xstate-store/src/fromStore.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { ActorLogic, Cast } from 'xstate';
import { ActorLogic } from 'xstate';
import { createStoreTransition, TransitionsFromEventPayloadMap } from './store';
import {
EventPayloadMap,
StoreContext,
Snapshot,
StoreSnapshot,
EventObject,
ExtractEventsFromPayloadMap,
StoreAssigner,
StorePropertyAssigner
ExtractEvents,
StoreAssigner
} from './types';

type StoreLogic<
Expand All @@ -18,34 +17,6 @@ type StoreLogic<
TEmitted extends EventObject
> = ActorLogic<StoreSnapshot<TContext>, TEvent, TInput, any, TEmitted>;

/**
* An actor logic creator which creates store [actor
* logic](https://stately.ai/docs/actors#actor-logic) for use with XState.
*
* @param initialContext The initial context for the store, either a function
* that returns context based on input, or the context itself
* @param transitions The transitions object defining how the context updates
* due to events
* @returns An actor logic creator function that creates store actor logic
*/
export function fromStore<
TContext extends StoreContext,
TEventPayloadMap extends EventPayloadMap,
TInput
>(
initialContext: ((input: TInput) => TContext) | TContext,
transitions: TransitionsFromEventPayloadMap<
TEventPayloadMap,
NoInfer<TContext>,
EventObject
>
): StoreLogic<
TContext,
ExtractEventsFromPayloadMap<TEventPayloadMap>,
TInput,
EventObject
>;

/**
* An actor logic creator which creates store [actor
* logic](https://stately.ai/docs/actors#actor-logic) for use with XState.
Expand All @@ -54,97 +25,54 @@ export function fromStore<
* @param config.context The initial context for the store, either a function
* that returns context based on input, or the context itself
* @param config.on An object defining the transitions for different event types
* @param config.types Optional object to define custom event types
* @param config.emits Optional object to define emitted event handlers
* @returns An actor logic creator function that creates store actor logic
*/
export function fromStore<
TContext extends StoreContext,
TEventPayloadMap extends EventPayloadMap,
TInput,
TTypes extends { emitted?: EventObject }
>(
config: {
context: ((input: TInput) => TContext) | TContext;
on: {
[K in keyof TEventPayloadMap & string]:
| StoreAssigner<
NoInfer<TContext>,
{ type: K } & TEventPayloadMap[K],
Cast<TTypes['emitted'], EventObject>
>
| StorePropertyAssigner<
NoInfer<TContext>,
{ type: K } & TEventPayloadMap[K],
Cast<TTypes['emitted'], EventObject>
>;
};
} & { types?: TTypes }
): StoreLogic<
TContext,
ExtractEventsFromPayloadMap<TEventPayloadMap>,
TInput,
TTypes['emitted'] extends EventObject ? TTypes['emitted'] : EventObject
>;
export function fromStore<
TContext extends StoreContext,
TEventPayloadMap extends EventPayloadMap,
TInput,
TTypes extends { emitted?: EventObject }
>(
initialContextOrObj:
| ((input: TInput) => TContext)
| TContext
| ({
context: ((input: TInput) => TContext) | TContext;
on: {
[K in keyof TEventPayloadMap & string]:
| StoreAssigner<
NoInfer<TContext>,
{ type: K } & TEventPayloadMap[K],
Cast<TTypes['emitted'], EventObject>
>
| StorePropertyAssigner<
NoInfer<TContext>,
{ type: K } & TEventPayloadMap[K],
Cast<TTypes['emitted'], EventObject>
>;
};
} & { types?: TTypes }),
transitions?: TransitionsFromEventPayloadMap<
TEventPayloadMap,
NoInfer<TContext>,
EventObject
>
): StoreLogic<
TEmitted extends EventPayloadMap
>(config: {
context: ((input: TInput) => TContext) | TContext;
on: {
[K in keyof TEventPayloadMap & string]: StoreAssigner<
NoInfer<TContext>,
{ type: K } & TEventPayloadMap[K],
ExtractEvents<TEmitted>
>;
};
emits?: {
[K in keyof TEmitted & string]: (
payload: { type: K } & TEmitted[K]
) => void;
};
}): StoreLogic<
TContext,
ExtractEventsFromPayloadMap<TEventPayloadMap>,
ExtractEvents<TEventPayloadMap>,
TInput,
TTypes['emitted'] extends EventObject ? TTypes['emitted'] : EventObject
ExtractEvents<TEmitted>
> {
let initialContext: ((input: TInput) => TContext) | TContext;
let transitionsObj: TransitionsFromEventPayloadMap<
const initialContext: ((input: TInput) => TContext) | TContext =
config.context;
const transitionsObj: TransitionsFromEventPayloadMap<
TEventPayloadMap,
NoInfer<TContext>,
EventObject
>;

if (
typeof initialContextOrObj === 'object' &&
'context' in initialContextOrObj
) {
initialContext = initialContextOrObj.context;
transitionsObj = initialContextOrObj.on;
} else {
initialContext = initialContextOrObj;
transitionsObj = transitions!;
}
> = config.on;

const transition = createStoreTransition(transitionsObj);
return {
transition: (snapshot, event, actorScope) => {
const [nextSnapshot, emittedEvents] = transition(snapshot, event);
const [nextSnapshot, effects] = transition(snapshot, event);

emittedEvents.forEach(actorScope.emit);
for (const effect of effects) {
if (typeof effect === 'function') {
effect();
} else {
actorScope.emit(effect as ExtractEvents<TEmitted>);
}
}

return nextSnapshot;
},
Expand Down
Loading

0 comments on commit 38aa9f5

Please sign in to comment.