Initial commit
This commit is contained in:
93
skills/xstate-v5/references/README.md
Normal file
93
skills/xstate-v5/references/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# XState v5 Reference Documentation
|
||||
|
||||
Comprehensive reference materials for XState v5 implementation and analysis in **backend applications**.
|
||||
|
||||
## Available References
|
||||
|
||||
### [Core API](./core-api.md)
|
||||
|
||||
Complete API reference for XState v5 including:
|
||||
|
||||
- Machine creation (`createMachine`, `setup`)
|
||||
- Actor management (`createActor`, lifecycle methods)
|
||||
- State configuration and transitions
|
||||
- Actions, guards, and services
|
||||
- Utility functions and type helpers
|
||||
|
||||
### [Actors](./actors.md)
|
||||
|
||||
Deep dive into the actor model:
|
||||
|
||||
- Actor types (state machine, promise, callback, transition, observable)
|
||||
- Actor lifecycle and communication
|
||||
- Spawning vs invoking actors
|
||||
- Actor systems and orchestration
|
||||
- Error handling and persistence
|
||||
|
||||
### [Common Patterns](./patterns.md)
|
||||
|
||||
Production-ready patterns and solutions:
|
||||
|
||||
- Loading states with retry logic
|
||||
- Form validation and submission
|
||||
- Authentication flows
|
||||
- Pagination and data fetching
|
||||
- Wizard/stepper interfaces
|
||||
- Parallel states and history
|
||||
- Debouncing and queue processing
|
||||
- Modal/dialog management
|
||||
- Connection handling with reconnection
|
||||
|
||||
### [TypeScript Integration](./typescript.md)
|
||||
|
||||
Complete TypeScript usage guide:
|
||||
|
||||
- Setup pattern with strong typing
|
||||
- Type inference and helpers
|
||||
- Generic machine factories
|
||||
- Event assertions and discriminated unions
|
||||
- Backend service types
|
||||
- Migration from v4 typegen
|
||||
|
||||
### [Testing Strategies](./testing.md)
|
||||
|
||||
Backend testing approaches:
|
||||
|
||||
- Unit testing state machines
|
||||
- Testing with xstate-audition
|
||||
- Mocking external services and databases
|
||||
- Testing async backend operations
|
||||
- Performance testing for server environments
|
||||
- Best practices for backend testing
|
||||
|
||||
## Quick Start
|
||||
|
||||
For new XState v5 implementations, start with:
|
||||
|
||||
1. **[TypeScript Integration](./typescript.md)** - Set up proper typing
|
||||
2. **[Core API](./core-api.md)** - Learn the fundamental APIs
|
||||
3. **[Common Patterns](./patterns.md)** - Apply proven solutions
|
||||
4. **[Testing Strategies](./testing.md)** - Ensure reliability
|
||||
|
||||
## External Resources
|
||||
|
||||
- [Official XState Documentation](https://stately.ai/docs/xstate)
|
||||
- [XState Visualizer](https://stately.ai/viz)
|
||||
- [XState Catalog](https://xstate-catalogue.com/)
|
||||
- [xstate-audition](https://boneskull.github.io/xstate-audition/) - Model-based testing
|
||||
|
||||
## Version Requirements
|
||||
|
||||
- **XState**: v5.x
|
||||
- **TypeScript**: 5.0 or greater
|
||||
- **Node.js**: 18+ recommended
|
||||
|
||||
## Contributing
|
||||
|
||||
To add or update reference documentation:
|
||||
|
||||
1. Follow the existing markdown structure
|
||||
2. Include practical code examples
|
||||
3. Document both best practices and anti-patterns
|
||||
4. Keep examples TypeScript-first
|
||||
5. Test all code examples for accuracy
|
||||
548
skills/xstate-v5/references/actors.md
Normal file
548
skills/xstate-v5/references/actors.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# XState v5 Actors Reference
|
||||
|
||||
## Actor Model Fundamentals
|
||||
|
||||
Actors are independent entities that:
|
||||
|
||||
- Maintain private, encapsulated state
|
||||
- Communicate via asynchronous message passing (events)
|
||||
- Process messages sequentially from an internal mailbox
|
||||
- Can create new child actors
|
||||
- Can only expose internal state through events or snapshots
|
||||
|
||||
## Actor Types Overview
|
||||
|
||||
| Actor Type | Receives Events | Sends Events | Spawns Actors | Has Output | Use Case |
|
||||
| ------------- | --------------- | ------------ | ------------- | ---------- | ------------------- |
|
||||
| State Machine | Yes | Yes | Yes | Yes | Complex state logic |
|
||||
| Promise | No | Yes | No | Yes | Async operations |
|
||||
| Transition | Yes | Yes | No | No | Pure reducers |
|
||||
| Callback | Yes | Yes | No | No | Imperative logic |
|
||||
| Observable | No | Yes | No | No | Streams |
|
||||
|
||||
## Creating and Managing Actors
|
||||
|
||||
### Basic Actor Creation
|
||||
|
||||
```typescript
|
||||
import { createActor } from 'xstate';
|
||||
|
||||
const actor = createActor(someLogic, {
|
||||
id: 'myActor',
|
||||
input: { initialData: 'value' },
|
||||
snapshot: previousSnapshot, // For restoration
|
||||
systemId: 'mySystem',
|
||||
logger: console.log,
|
||||
});
|
||||
|
||||
// Lifecycle
|
||||
actor.start(); // Must call to begin processing
|
||||
actor.stop(); // Stops actor and all children
|
||||
```
|
||||
|
||||
### Actor Subscription
|
||||
|
||||
```typescript
|
||||
// Simple observer function
|
||||
const subscription = actor.subscribe((snapshot) => {
|
||||
console.log('State:', snapshot.value);
|
||||
console.log('Context:', snapshot.context);
|
||||
});
|
||||
|
||||
// Full observer object
|
||||
actor.subscribe({
|
||||
next(snapshot) {
|
||||
console.log('New snapshot:', snapshot);
|
||||
},
|
||||
error(err) {
|
||||
console.error('Actor error:', err);
|
||||
},
|
||||
complete() {
|
||||
console.log('Actor completed');
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
subscription.unsubscribe();
|
||||
```
|
||||
|
||||
### Snapshot Access
|
||||
|
||||
```typescript
|
||||
// Synchronous snapshot access
|
||||
const currentSnapshot = actor.getSnapshot();
|
||||
|
||||
// Persistable snapshot (serializable)
|
||||
const persistedSnapshot = actor.getPersistedSnapshot();
|
||||
localStorage.setItem('actor-state', JSON.stringify(persistedSnapshot));
|
||||
```
|
||||
|
||||
## State Machine Actors
|
||||
|
||||
The most powerful actor type, implementing full statechart semantics.
|
||||
|
||||
```typescript
|
||||
import { createMachine, createActor } from 'xstate';
|
||||
|
||||
const toggleMachine = createMachine({
|
||||
id: 'toggle',
|
||||
initial: 'inactive',
|
||||
context: { count: 0 },
|
||||
states: {
|
||||
inactive: {
|
||||
on: {
|
||||
TOGGLE: {
|
||||
target: 'active',
|
||||
actions: assign({ count: ({ context }) => context.count + 1 }),
|
||||
},
|
||||
},
|
||||
},
|
||||
active: {
|
||||
on: { TOGGLE: 'inactive' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const actor = createActor(toggleMachine);
|
||||
actor.start();
|
||||
actor.send({ type: 'TOGGLE' });
|
||||
```
|
||||
|
||||
## Promise Actors
|
||||
|
||||
Handle single async operations that resolve or reject.
|
||||
|
||||
```typescript
|
||||
import { fromPromise, createActor } from 'xstate';
|
||||
|
||||
// Basic promise actor
|
||||
const fetchLogic = fromPromise(async () => {
|
||||
const response = await fetch('/api/data');
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// With input parameter
|
||||
const fetchWithInput = fromPromise(async ({ input }) => {
|
||||
const response = await fetch(`/api/data/${input.id}`);
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// With abort signal
|
||||
const cancellableFetch = fromPromise(async ({ input, signal }) => {
|
||||
const response = await fetch('/api/data', { signal });
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// Usage
|
||||
const actor = createActor(fetchWithInput, {
|
||||
input: { id: '123' },
|
||||
});
|
||||
|
||||
actor.subscribe({
|
||||
next: (snapshot) => {
|
||||
if (snapshot.status === 'done') {
|
||||
console.log('Result:', snapshot.output);
|
||||
}
|
||||
},
|
||||
error: (err) => console.error('Failed:', err),
|
||||
});
|
||||
|
||||
actor.start();
|
||||
```
|
||||
|
||||
## Transition Actors
|
||||
|
||||
Pure reducers that transform state based on events.
|
||||
|
||||
```typescript
|
||||
import { fromTransition, createActor } from 'xstate';
|
||||
|
||||
const counterLogic = fromTransition(
|
||||
(state, event) => {
|
||||
switch (event.type) {
|
||||
case 'INC':
|
||||
return { ...state, count: state.count + 1 };
|
||||
case 'DEC':
|
||||
return { ...state, count: state.count - 1 };
|
||||
case 'RESET':
|
||||
return { count: 0 };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
{ count: 0 }, // Initial state
|
||||
);
|
||||
|
||||
const actor = createActor(counterLogic);
|
||||
actor.start();
|
||||
actor.send({ type: 'INC' });
|
||||
console.log(actor.getSnapshot().context.count); // 1
|
||||
```
|
||||
|
||||
## Callback Actors
|
||||
|
||||
Imperative actors for complex side effects and cleanup.
|
||||
|
||||
```typescript
|
||||
import { fromCallback, createActor } from 'xstate';
|
||||
|
||||
const intervalLogic = fromCallback(({ sendBack, receive, input }) => {
|
||||
// Setup
|
||||
console.log('Starting with input:', input);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
sendBack({ type: 'TICK', timestamp: Date.now() });
|
||||
}, 1000);
|
||||
|
||||
// Event handler
|
||||
receive((event) => {
|
||||
if (event.type === 'STOP_TICKING') {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup function (called on stop)
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
console.log('Cleaned up');
|
||||
};
|
||||
});
|
||||
|
||||
const actor = createActor(intervalLogic, {
|
||||
input: { message: 'Timer started' },
|
||||
});
|
||||
|
||||
actor.subscribe((snapshot) => {
|
||||
console.log('Snapshot:', snapshot);
|
||||
});
|
||||
|
||||
actor.start();
|
||||
// Later: actor.send({ type: 'STOP_TICKING' });
|
||||
// Or: actor.stop();
|
||||
```
|
||||
|
||||
## Observable Actors
|
||||
|
||||
Integration with reactive streams (RxJS, etc.).
|
||||
|
||||
```typescript
|
||||
import { fromObservable, createActor } from 'xstate';
|
||||
import { interval } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
const timerLogic = fromObservable(() =>
|
||||
interval(1000).pipe(map((i) => ({ type: 'TICK', count: i }))),
|
||||
);
|
||||
|
||||
const actor = createActor(timerLogic);
|
||||
actor.subscribe((snapshot) => {
|
||||
console.log('Event from observable:', snapshot.context);
|
||||
});
|
||||
actor.start();
|
||||
```
|
||||
|
||||
## Invoking vs Spawning Actors
|
||||
|
||||
### Invoked Actors (State-based)
|
||||
|
||||
Tied to state lifecycle - start on entry, stop on exit.
|
||||
|
||||
```typescript
|
||||
const machine = createMachine({
|
||||
states: {
|
||||
loading: {
|
||||
invoke: {
|
||||
id: 'fetcher',
|
||||
src: fromPromise(async () => {
|
||||
const res = await fetch('/api');
|
||||
return res.json();
|
||||
}),
|
||||
input: ({ context }) => ({ url: context.apiUrl }),
|
||||
onDone: {
|
||||
target: 'success',
|
||||
actions: assign({
|
||||
data: ({ event }) => event.output,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'failure',
|
||||
actions: assign({
|
||||
error: ({ event }) => event.error,
|
||||
}),
|
||||
},
|
||||
onSnapshot: {
|
||||
actions: ({ event }) => {
|
||||
console.log('Invoked actor snapshot:', event.snapshot);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
success: {},
|
||||
failure: {},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Spawned Actors (Action-based)
|
||||
|
||||
Created dynamically, managed manually.
|
||||
|
||||
```typescript
|
||||
import { spawn, stopChild } from 'xstate';
|
||||
|
||||
const parentMachine = createMachine({
|
||||
context: {
|
||||
todos: [],
|
||||
todoActors: {},
|
||||
},
|
||||
on: {
|
||||
ADD_TODO: {
|
||||
actions: assign({
|
||||
todoActors: ({ context, spawn }) => {
|
||||
const id = Math.random().toString();
|
||||
const todoActor = spawn(todoMachine, {
|
||||
id: `todo-${id}`,
|
||||
input: { text: event.text },
|
||||
syncSnapshot: true, // Sync snapshots with parent
|
||||
});
|
||||
|
||||
return {
|
||||
...context.todoActors,
|
||||
[id]: todoActor,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
REMOVE_TODO: {
|
||||
actions: [
|
||||
stopChild(({ context, event }) => `todo-${event.id}`),
|
||||
assign({
|
||||
todoActors: ({ context, event }) => {
|
||||
const { [event.id]: removed, ...rest } = context.todoActors;
|
||||
return rest;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Actor Communication
|
||||
|
||||
### Sending Between Actors
|
||||
|
||||
```typescript
|
||||
import { sendTo, sendParent } from 'xstate';
|
||||
|
||||
const childMachine = createMachine({
|
||||
entry: [
|
||||
// Send to parent
|
||||
sendParent({ type: 'CHILD_READY' }),
|
||||
// Or use emit (preferred in v5)
|
||||
emit({ type: 'CHILD_READY' }),
|
||||
],
|
||||
on: {
|
||||
PING: {
|
||||
actions: emit({ type: 'PONG' }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parentMachine = createMachine({
|
||||
invoke: {
|
||||
id: 'child',
|
||||
src: childMachine,
|
||||
},
|
||||
on: {
|
||||
SEND_TO_CHILD: {
|
||||
actions: sendTo('child', { type: 'PING' }),
|
||||
},
|
||||
PONG: {
|
||||
actions: log('Received pong from child'),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Actor System
|
||||
|
||||
```typescript
|
||||
// Access system from any actor
|
||||
const actor = createActor(machine);
|
||||
actor.start();
|
||||
|
||||
// Get actor from system
|
||||
const childActor = actor.system.get('childId');
|
||||
|
||||
// Inspect all actors in system
|
||||
actor.system._set.forEach((actor, id) => {
|
||||
console.log(`Actor ${id}:`, actor.getSnapshot());
|
||||
});
|
||||
```
|
||||
|
||||
## Waiting for Actors
|
||||
|
||||
```typescript
|
||||
import { waitFor } from 'xstate';
|
||||
|
||||
// Wait for condition
|
||||
const doneSnapshot = await waitFor(
|
||||
actor,
|
||||
(snapshot) => snapshot.status === 'done',
|
||||
{ timeout: 5000 }, // Optional timeout
|
||||
);
|
||||
|
||||
// Convert to promise
|
||||
import { toPromise } from 'xstate';
|
||||
|
||||
const result = await toPromise(actor);
|
||||
// Resolves with output when done
|
||||
// Rejects with error when failed
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Promise Actor Errors
|
||||
|
||||
```typescript
|
||||
const errorProneLogic = fromPromise(async () => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
|
||||
const machine = createMachine({
|
||||
invoke: {
|
||||
src: errorProneLogic,
|
||||
onError: {
|
||||
target: 'errorState',
|
||||
actions: ({ event }) => {
|
||||
console.error('Error:', event.error);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Callback Actor Error Reporting
|
||||
|
||||
```typescript
|
||||
const callbackWithErrors = fromCallback(({ sendBack }) => {
|
||||
try {
|
||||
// risky operation
|
||||
} catch (error) {
|
||||
sendBack({ type: 'error.platform', error });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Actor Persistence
|
||||
|
||||
```typescript
|
||||
// Save actor state
|
||||
const actor = createActor(machine);
|
||||
actor.subscribe(() => {
|
||||
const snapshot = actor.getPersistedSnapshot();
|
||||
localStorage.setItem('actor-state', JSON.stringify(snapshot));
|
||||
});
|
||||
actor.start();
|
||||
|
||||
// Restore actor state
|
||||
const savedState = localStorage.getItem('actor-state');
|
||||
if (savedState) {
|
||||
const restoredActor = createActor(machine, {
|
||||
snapshot: JSON.parse(savedState),
|
||||
});
|
||||
restoredActor.start();
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Choose the Right Actor Type
|
||||
|
||||
- **State machines**: Complex logic with multiple states
|
||||
- **Promises**: Single async operations
|
||||
- **Transitions**: Pure state reducers
|
||||
- **Callbacks**: Imperative code with cleanup
|
||||
- **Observables**: Stream integrations
|
||||
|
||||
### 2. Actor Lifecycle Management
|
||||
|
||||
```typescript
|
||||
// Always subscribe before starting
|
||||
const subscription = actor.subscribe(observer);
|
||||
actor.start();
|
||||
|
||||
// Clean up properly
|
||||
subscription.unsubscribe();
|
||||
actor.stop();
|
||||
```
|
||||
|
||||
### 3. Error Boundaries
|
||||
|
||||
```typescript
|
||||
// Always handle errors in parent
|
||||
const parentMachine = createMachine({
|
||||
invoke: {
|
||||
src: childLogic,
|
||||
onError: {
|
||||
actions: [
|
||||
log('Child failed'),
|
||||
assign({ error: ({ event }) => event.error }),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Input Validation
|
||||
|
||||
```typescript
|
||||
const actorWithInput = fromPromise(async ({ input }) => {
|
||||
// Validate input
|
||||
if (!input?.id) {
|
||||
throw new Error('ID required');
|
||||
}
|
||||
return fetchData(input.id);
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Sync vs Async Snapshots
|
||||
|
||||
```typescript
|
||||
// Spawned actors can sync snapshots with parent
|
||||
spawn(childMachine, {
|
||||
id: 'child',
|
||||
syncSnapshot: true, // Parent re-renders on child changes
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Actors
|
||||
|
||||
```typescript
|
||||
import { createActor } from 'xstate';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
test('actor processes events correctly', async () => {
|
||||
const actor = createActor(machine);
|
||||
|
||||
const states = [];
|
||||
actor.subscribe((snapshot) => {
|
||||
states.push(snapshot.value);
|
||||
});
|
||||
|
||||
actor.start();
|
||||
actor.send({ type: 'EVENT1' });
|
||||
actor.send({ type: 'EVENT2' });
|
||||
|
||||
expect(states).toEqual(['initial', 'state1', 'state2']);
|
||||
});
|
||||
|
||||
test('promise actor resolves', async () => {
|
||||
const actor = createActor(promiseLogic);
|
||||
actor.start();
|
||||
|
||||
const result = await toPromise(actor);
|
||||
expect(result).toEqual({ data: 'expected' });
|
||||
});
|
||||
```
|
||||
495
skills/xstate-v5/references/core-api.md
Normal file
495
skills/xstate-v5/references/core-api.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# XState v5 Core API Reference
|
||||
|
||||
## Machine Creation
|
||||
|
||||
### `createMachine(config)`
|
||||
|
||||
Creates a state machine configuration.
|
||||
|
||||
```typescript
|
||||
import { createMachine } from 'xstate';
|
||||
|
||||
const machine = createMachine({
|
||||
id: 'machineId', // Unique identifier
|
||||
initial: 'stateName', // Initial state
|
||||
context: {}, // Initial context data
|
||||
types: {}, // TypeScript type definitions
|
||||
states: {}, // State definitions
|
||||
on: {}, // Global transitions
|
||||
entry: [], // Entry actions
|
||||
exit: [], // Exit actions
|
||||
after: {}, // Delayed transitions
|
||||
always: [], // Eventless transitions
|
||||
invoke: {}, // Invoked services
|
||||
tags: [], // State tags
|
||||
description: '', // Machine description
|
||||
});
|
||||
```
|
||||
|
||||
### `setup(config)`
|
||||
|
||||
Configures reusable machine logic with strong typing.
|
||||
|
||||
```typescript
|
||||
import { setup } from 'xstate';
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as ContextType,
|
||||
events: {} as EventType,
|
||||
input: {} as InputType,
|
||||
output: {} as OutputType,
|
||||
},
|
||||
actions: {
|
||||
// Named action implementations
|
||||
actionName: (context, event, meta) => {
|
||||
/* ... */
|
||||
},
|
||||
},
|
||||
guards: {
|
||||
// Named guard implementations
|
||||
guardName: (context, event, meta) => boolean,
|
||||
},
|
||||
actors: {
|
||||
// Actor logic definitions
|
||||
actorName: fromPromise(() => fetch('/api')),
|
||||
},
|
||||
delays: {
|
||||
// Named delay functions
|
||||
delayName: (context, event) => 1000,
|
||||
},
|
||||
}).createMachine({
|
||||
// Machine configuration
|
||||
});
|
||||
```
|
||||
|
||||
## Actor Creation and Management
|
||||
|
||||
### `createActor(logic, options?)`
|
||||
|
||||
Creates an actor instance from machine or actor logic.
|
||||
|
||||
```typescript
|
||||
const actor = createActor(machine, {
|
||||
id: 'actorId', // Actor identifier
|
||||
input: {}, // Initial input
|
||||
snapshot: {}, // Restored snapshot
|
||||
systemId: 'systemId', // Actor system ID
|
||||
logger: console.log, // Custom logger
|
||||
inspect: (event) => {}, // Inspection handler
|
||||
});
|
||||
```
|
||||
|
||||
### Actor Methods
|
||||
|
||||
```typescript
|
||||
// Lifecycle
|
||||
actor.start(); // Start the actor
|
||||
actor.stop(); // Stop the actor
|
||||
actor.getSnapshot(); // Get current snapshot
|
||||
actor.getPersistedSnapshot(); // Get persistable snapshot
|
||||
|
||||
// Communication
|
||||
actor.send(event); // Send an event
|
||||
actor.send({ type: 'EVENT' }); // Send event object
|
||||
|
||||
// Subscription
|
||||
const subscription = actor.subscribe(observer);
|
||||
subscription.unsubscribe();
|
||||
|
||||
// System
|
||||
actor.system; // Access actor system
|
||||
```
|
||||
|
||||
## State Configuration
|
||||
|
||||
### State Node Properties
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'atomic' | 'compound' | 'parallel' | 'final' | 'history',
|
||||
initial: 'childStateName', // For compound states
|
||||
context: {}, // State-specific context
|
||||
states: {}, // Child states
|
||||
on: {}, // Transitions
|
||||
entry: [], // Entry actions
|
||||
exit: [], // Exit actions
|
||||
always: [], // Eventless transitions
|
||||
after: {}, // Delayed transitions
|
||||
invoke: {}, // Invoked services
|
||||
tags: [], // State tags
|
||||
description: '', // State description
|
||||
meta: {}, // Metadata
|
||||
history: 'shallow' | 'deep', // For history states
|
||||
}
|
||||
```
|
||||
|
||||
### Transition Configuration
|
||||
|
||||
```typescript
|
||||
{
|
||||
target: 'stateName' | ['state1', 'state2'], // Target state(s)
|
||||
guard: 'guardName' | guardFunction, // Condition
|
||||
actions: ['action1', 'action2'], // Actions to execute
|
||||
reenter: boolean, // Re-enter state
|
||||
description: 'Transition description', // Documentation
|
||||
}
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
### `assign(assignment)`
|
||||
|
||||
Updates machine context immutably.
|
||||
|
||||
```typescript
|
||||
// Function updater
|
||||
assign({
|
||||
count: ({ context }) => context.count + 1,
|
||||
user: ({ context, event }) => event.user,
|
||||
});
|
||||
|
||||
// Object updater
|
||||
assign({ count: 5, user: null });
|
||||
|
||||
// Single property
|
||||
assign(({ context }) => ({
|
||||
...context,
|
||||
modified: true,
|
||||
}));
|
||||
```
|
||||
|
||||
### `raise(event)`
|
||||
|
||||
Raises an event internally.
|
||||
|
||||
```typescript
|
||||
raise({ type: 'INTERNAL_EVENT' });
|
||||
raise(({ context }) => ({
|
||||
type: 'DYNAMIC_EVENT',
|
||||
data: context.someValue,
|
||||
}));
|
||||
```
|
||||
|
||||
### `sendTo(actor, event)`
|
||||
|
||||
Sends event to another actor.
|
||||
|
||||
```typescript
|
||||
sendTo('actorId', { type: 'MESSAGE' });
|
||||
sendTo(({ context }) => context.someActor, { type: 'EVENT' });
|
||||
```
|
||||
|
||||
### `emit(event)`
|
||||
|
||||
Emits an event to parent actor.
|
||||
|
||||
```typescript
|
||||
emit({ type: 'CHILD_EVENT', data: value });
|
||||
```
|
||||
|
||||
### `log(message)`
|
||||
|
||||
Logs a message (for debugging).
|
||||
|
||||
```typescript
|
||||
log('State entered');
|
||||
log(({ context }) => `Count: ${context.count}`);
|
||||
```
|
||||
|
||||
### `stop(actorId)`
|
||||
|
||||
Stops a spawned actor.
|
||||
|
||||
```typescript
|
||||
stop('childActorId');
|
||||
```
|
||||
|
||||
### `cancel(sendId)`
|
||||
|
||||
Cancels a delayed send.
|
||||
|
||||
```typescript
|
||||
cancel('delayedSendId');
|
||||
```
|
||||
|
||||
### `enqueueActions(callback)`
|
||||
|
||||
Enqueues actions conditionally at runtime. Replaces v4's `pure()` and `choose()`.
|
||||
|
||||
```typescript
|
||||
import { enqueueActions } from 'xstate';
|
||||
|
||||
// Basic conditional actions
|
||||
entry: enqueueActions(({ context, event, enqueue, check }) => {
|
||||
// Conditionally enqueue actions
|
||||
if (context.count > 0) {
|
||||
enqueue('logPositive');
|
||||
}
|
||||
|
||||
// Use check() for guards
|
||||
if (check({ type: 'hasPermission' })) {
|
||||
enqueue('performSecureAction');
|
||||
}
|
||||
|
||||
// Enqueue multiple actions
|
||||
enqueue([
|
||||
{ type: 'log', params: { message: 'Processing' } },
|
||||
'processData',
|
||||
assign({ processing: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
// With parameters
|
||||
enqueueActions(({ context, enqueue }, params: { threshold: number }) => {
|
||||
if (context.value > params.threshold) {
|
||||
enqueue('handleHighValue');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Guards
|
||||
|
||||
### Guard Functions
|
||||
|
||||
```typescript
|
||||
// Inline guard
|
||||
guard: ({ context, event }) => context.count > 0;
|
||||
|
||||
// Named guard
|
||||
guard: 'isValid';
|
||||
|
||||
// Negated guard
|
||||
guard: not('isInvalid');
|
||||
|
||||
// Combined guards
|
||||
guard: and(['isValid', 'isAuthorized']);
|
||||
guard: or(['isAdmin', 'isOwner']);
|
||||
```
|
||||
|
||||
### Guard Helpers
|
||||
|
||||
```typescript
|
||||
import { not, and, or, stateIn } from 'xstate';
|
||||
|
||||
not(guard); // Negates a guard
|
||||
and([guard1, guard2]); // All must be true
|
||||
or([guard1, guard2]); // At least one must be true
|
||||
stateIn('state.path'); // Checks if in state
|
||||
```
|
||||
|
||||
## Invoked Services
|
||||
|
||||
### Service Configuration
|
||||
|
||||
```typescript
|
||||
invoke: {
|
||||
id: 'serviceId',
|
||||
src: 'serviceName' | actorLogic,
|
||||
input: ({ context, event }) => ({}),
|
||||
onDone: {
|
||||
target: 'success',
|
||||
actions: assign({ data: ({ event }) => event.output }),
|
||||
},
|
||||
onError: {
|
||||
target: 'failure',
|
||||
actions: assign({ error: ({ event }) => event.error }),
|
||||
},
|
||||
onSnapshot: {
|
||||
actions: ({ event }) => console.log(event.snapshot),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Invocations
|
||||
|
||||
```typescript
|
||||
invoke: [
|
||||
{ id: 'service1', src: 'api1' },
|
||||
{ id: 'service2', src: 'api2' },
|
||||
];
|
||||
```
|
||||
|
||||
## Spawning Actors
|
||||
|
||||
### `spawn(logic, options?)`
|
||||
|
||||
Spawns a child actor.
|
||||
|
||||
```typescript
|
||||
import { spawn } from 'xstate';
|
||||
|
||||
// In an action
|
||||
spawn(childMachine, {
|
||||
id: 'childId',
|
||||
systemId: 'childSystem',
|
||||
input: { initial: 'data' },
|
||||
syncSnapshot: true,
|
||||
});
|
||||
```
|
||||
|
||||
### `stopChild(actorId)`
|
||||
|
||||
Stops a spawned child actor.
|
||||
|
||||
```typescript
|
||||
stopChild('childId');
|
||||
```
|
||||
|
||||
## Delayed Transitions
|
||||
|
||||
### After Configuration
|
||||
|
||||
```typescript
|
||||
after: {
|
||||
1000: 'timeout', // Fixed delay
|
||||
DELAY_NAME: 'delayed', // Named delay
|
||||
[({ context }) => context.delay]: 'dynamic', // Dynamic delay
|
||||
}
|
||||
```
|
||||
|
||||
## Eventless Transitions
|
||||
|
||||
### Always Configuration
|
||||
|
||||
```typescript
|
||||
always: [
|
||||
{ target: 'state1', guard: 'condition1' },
|
||||
{ target: 'state2', guard: 'condition2' },
|
||||
{ target: 'default' }, // Fallback
|
||||
];
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### `waitFor(actor, predicate, options?)`
|
||||
|
||||
Waits for an actor to reach a specific condition.
|
||||
|
||||
```typescript
|
||||
const snapshot = await waitFor(actor, (snapshot) => snapshot.matches('done'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
### `toPromise(actor)`
|
||||
|
||||
Converts an actor to a Promise.
|
||||
|
||||
```typescript
|
||||
const result = await toPromise(actor);
|
||||
```
|
||||
|
||||
### `createEmptyActor()`
|
||||
|
||||
Creates an actor that does nothing.
|
||||
|
||||
```typescript
|
||||
const emptyActor = createEmptyActor();
|
||||
```
|
||||
|
||||
## Type Helpers
|
||||
|
||||
### `ActorRefFrom<T>`
|
||||
|
||||
Gets the ActorRef type from logic.
|
||||
|
||||
```typescript
|
||||
type MyActorRef = ActorRefFrom<typeof machine>;
|
||||
```
|
||||
|
||||
### `SnapshotFrom<T>`
|
||||
|
||||
Gets the Snapshot type from logic or ActorRef.
|
||||
|
||||
```typescript
|
||||
type MySnapshot = SnapshotFrom<typeof machine>;
|
||||
```
|
||||
|
||||
### `EventFromLogic<T>`
|
||||
|
||||
Gets the event union type from logic.
|
||||
|
||||
```typescript
|
||||
type MyEvents = EventFromLogic<typeof machine>;
|
||||
```
|
||||
|
||||
### `StateValueFrom<T>`
|
||||
|
||||
Gets the state value type from logic.
|
||||
|
||||
```typescript
|
||||
type MyStateValue = StateValueFrom<typeof machine>;
|
||||
```
|
||||
|
||||
### `ContextFrom<T>`
|
||||
|
||||
Gets the context type from logic.
|
||||
|
||||
```typescript
|
||||
type MyContext = ContextFrom<typeof machine>;
|
||||
```
|
||||
|
||||
## Inspection and Debugging
|
||||
|
||||
### Inspector API
|
||||
|
||||
```typescript
|
||||
const actor = createActor(machine, {
|
||||
inspect: (inspectionEvent) => {
|
||||
if (inspectionEvent.type === '@xstate.snapshot') {
|
||||
console.log('Snapshot:', inspectionEvent.snapshot);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Inspection Event Types
|
||||
|
||||
- `@xstate.actor` - Actor created/stopped
|
||||
- `@xstate.snapshot` - Snapshot updated
|
||||
- `@xstate.event` - Event processed
|
||||
- `@xstate.microstep` - Microstep taken
|
||||
|
||||
## State Methods
|
||||
|
||||
### `state.matches(stateValue)`
|
||||
|
||||
Checks if in a specific state.
|
||||
|
||||
```typescript
|
||||
if (state.matches('loading')) {
|
||||
/* ... */
|
||||
}
|
||||
if (state.matches({ form: 'valid' })) {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### `state.hasTag(tag)`
|
||||
|
||||
Checks if state has a tag.
|
||||
|
||||
```typescript
|
||||
if (state.hasTag('loading')) {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### `state.can(event)`
|
||||
|
||||
Checks if an event can cause a transition.
|
||||
|
||||
```typescript
|
||||
if (state.can({ type: 'SUBMIT' })) {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### `state.nextEvents`
|
||||
|
||||
Gets available events from current state.
|
||||
|
||||
```typescript
|
||||
const events = state.nextEvents; // ['SUBMIT', 'CANCEL']
|
||||
```
|
||||
929
skills/xstate-v5/references/patterns.md
Normal file
929
skills/xstate-v5/references/patterns.md
Normal file
@@ -0,0 +1,929 @@
|
||||
# XState v5 Common Patterns
|
||||
|
||||
## Conditional Actions Pattern
|
||||
|
||||
### Using enqueueActions (replaces v4's pure/choose)
|
||||
|
||||
```typescript
|
||||
const machine = createMachine({
|
||||
context: {
|
||||
count: 0,
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
},
|
||||
on: {
|
||||
PROCESS: {
|
||||
actions: enqueueActions(({ context, event, enqueue, check }) => {
|
||||
// Conditional logic at runtime
|
||||
if (context.count > 10) {
|
||||
enqueue('notifyHighCount');
|
||||
}
|
||||
|
||||
// Check guards
|
||||
if (check('isAuthenticated')) {
|
||||
enqueue('processAuthenticatedUser');
|
||||
|
||||
if (context.isAdmin) {
|
||||
enqueue('grantAdminPrivileges');
|
||||
}
|
||||
} else {
|
||||
enqueue('redirectToLogin');
|
||||
}
|
||||
|
||||
// Dynamic action selection
|
||||
const action = context.count % 2 === 0 ? 'handleEven' : 'handleOdd';
|
||||
enqueue(action);
|
||||
|
||||
// Always executed
|
||||
enqueue(assign({ lastProcessed: Date.now() }));
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Loading States Pattern
|
||||
|
||||
### Basic Loading Pattern
|
||||
|
||||
```typescript
|
||||
const fetchMachine = createMachine({
|
||||
initial: 'idle',
|
||||
context: {
|
||||
data: null,
|
||||
error: null,
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
FETCH: 'loading',
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
entry: assign({ error: null }), // Clear previous errors
|
||||
invoke: {
|
||||
src: 'fetchData',
|
||||
onDone: {
|
||||
target: 'success',
|
||||
actions: assign({
|
||||
data: ({ event }) => event.output,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'failure',
|
||||
actions: assign({
|
||||
error: ({ event }) => event.error,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
success: {
|
||||
on: {
|
||||
REFETCH: 'loading',
|
||||
},
|
||||
},
|
||||
failure: {
|
||||
on: {
|
||||
RETRY: 'loading',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### With Retry Logic
|
||||
|
||||
```typescript
|
||||
const retryMachine = createMachine({
|
||||
context: {
|
||||
retries: 0,
|
||||
maxRetries: 3,
|
||||
data: null,
|
||||
error: null,
|
||||
},
|
||||
states: {
|
||||
loading: {
|
||||
invoke: {
|
||||
src: 'fetchData',
|
||||
onDone: {
|
||||
target: 'success',
|
||||
actions: assign({
|
||||
data: ({ event }) => event.output,
|
||||
retries: 0, // Reset on success
|
||||
}),
|
||||
},
|
||||
onError: [
|
||||
{
|
||||
target: 'retrying',
|
||||
guard: ({ context }) => context.retries < context.maxRetries,
|
||||
actions: assign({
|
||||
retries: ({ context }) => context.retries + 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: 'failure',
|
||||
actions: assign({
|
||||
error: ({ event }) => event.error,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
retrying: {
|
||||
after: {
|
||||
1000: 'loading', // Retry after 1 second
|
||||
},
|
||||
},
|
||||
success: {},
|
||||
failure: {},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Form Validation Pattern
|
||||
|
||||
### Multi-field Form Validation
|
||||
|
||||
```typescript
|
||||
const formMachine = setup({
|
||||
types: {
|
||||
context: {} as {
|
||||
fields: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
errors: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
touched: {
|
||||
email: boolean;
|
||||
password: boolean;
|
||||
};
|
||||
},
|
||||
},
|
||||
guards: {
|
||||
isEmailValid: ({ context }) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(context.fields.email),
|
||||
isPasswordValid: ({ context }) => context.fields.password.length >= 8,
|
||||
isFormValid: ({ context }) =>
|
||||
!context.errors.email && !context.errors.password,
|
||||
},
|
||||
}).createMachine({
|
||||
initial: 'editing',
|
||||
context: {
|
||||
fields: { email: '', password: '' },
|
||||
errors: {},
|
||||
touched: { email: false, password: false },
|
||||
},
|
||||
states: {
|
||||
editing: {
|
||||
on: {
|
||||
UPDATE_EMAIL: {
|
||||
actions: [
|
||||
assign({
|
||||
fields: ({ context, event }) => ({
|
||||
...context.fields,
|
||||
email: event.value,
|
||||
}),
|
||||
touched: ({ context }) => ({
|
||||
...context.touched,
|
||||
email: true,
|
||||
}),
|
||||
}),
|
||||
'validateEmail',
|
||||
],
|
||||
},
|
||||
UPDATE_PASSWORD: {
|
||||
actions: [
|
||||
assign({
|
||||
fields: ({ context, event }) => ({
|
||||
...context.fields,
|
||||
password: event.value,
|
||||
}),
|
||||
touched: ({ context }) => ({
|
||||
...context.touched,
|
||||
password: true,
|
||||
}),
|
||||
}),
|
||||
'validatePassword',
|
||||
],
|
||||
},
|
||||
SUBMIT: {
|
||||
target: 'validating',
|
||||
},
|
||||
},
|
||||
},
|
||||
validating: {
|
||||
always: [
|
||||
{
|
||||
target: 'submitting',
|
||||
guard: 'isFormValid',
|
||||
},
|
||||
{
|
||||
target: 'editing',
|
||||
actions: assign({
|
||||
touched: { email: true, password: true },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
submitting: {
|
||||
invoke: {
|
||||
src: 'submitForm',
|
||||
input: ({ context }) => context.fields,
|
||||
onDone: {
|
||||
target: 'success',
|
||||
},
|
||||
onError: {
|
||||
target: 'editing',
|
||||
actions: assign({
|
||||
errors: ({ event }) => event.error.fieldErrors || {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: 'final',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Authentication Flow Pattern
|
||||
|
||||
```typescript
|
||||
const authMachine = createMachine({
|
||||
initial: 'checkingAuth',
|
||||
context: {
|
||||
user: null,
|
||||
token: null,
|
||||
},
|
||||
states: {
|
||||
checkingAuth: {
|
||||
invoke: {
|
||||
src: 'checkStoredAuth',
|
||||
onDone: [
|
||||
{
|
||||
target: 'authenticated',
|
||||
guard: ({ event }) => !!event.output.token,
|
||||
actions: assign({
|
||||
user: ({ event }) => event.output.user,
|
||||
token: ({ event }) => event.output.token,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: 'unauthenticated',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
unauthenticated: {
|
||||
on: {
|
||||
LOGIN: 'authenticating',
|
||||
REGISTER: 'registering',
|
||||
},
|
||||
},
|
||||
authenticating: {
|
||||
invoke: {
|
||||
src: 'authenticate',
|
||||
input: ({ event }) => ({
|
||||
email: event.email,
|
||||
password: event.password,
|
||||
}),
|
||||
onDone: {
|
||||
target: 'authenticated',
|
||||
actions: [
|
||||
assign({
|
||||
user: ({ event }) => event.output.user,
|
||||
token: ({ event }) => event.output.token,
|
||||
}),
|
||||
'storeAuth',
|
||||
],
|
||||
},
|
||||
onError: {
|
||||
target: 'unauthenticated',
|
||||
actions: 'showError',
|
||||
},
|
||||
},
|
||||
},
|
||||
registering: {
|
||||
// Similar to authenticating
|
||||
},
|
||||
authenticated: {
|
||||
on: {
|
||||
LOGOUT: {
|
||||
target: 'unauthenticated',
|
||||
actions: [assign({ user: null, token: null }), 'clearStoredAuth'],
|
||||
},
|
||||
TOKEN_EXPIRED: 'refreshing',
|
||||
},
|
||||
},
|
||||
refreshing: {
|
||||
invoke: {
|
||||
src: 'refreshToken',
|
||||
onDone: {
|
||||
target: 'authenticated',
|
||||
actions: assign({
|
||||
token: ({ event }) => event.output.token,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'unauthenticated',
|
||||
actions: ['clearStoredAuth'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Pagination Pattern
|
||||
|
||||
```typescript
|
||||
const paginationMachine = createMachine({
|
||||
context: {
|
||||
items: [],
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
pageSize: 10,
|
||||
totalItems: 0,
|
||||
isLoading: false,
|
||||
},
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
LOAD_PAGE: {
|
||||
target: 'loading',
|
||||
},
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
entry: assign({ isLoading: true }),
|
||||
exit: assign({ isLoading: false }),
|
||||
invoke: {
|
||||
src: 'fetchPage',
|
||||
input: ({ context, event }) => ({
|
||||
page: event.page || context.currentPage,
|
||||
pageSize: context.pageSize,
|
||||
}),
|
||||
onDone: {
|
||||
target: 'idle',
|
||||
actions: assign({
|
||||
items: ({ event }) => event.output.items,
|
||||
currentPage: ({ event }) => event.output.page,
|
||||
totalPages: ({ event }) => event.output.totalPages,
|
||||
totalItems: ({ event }) => event.output.totalItems,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'error',
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
RETRY: 'loading',
|
||||
},
|
||||
},
|
||||
},
|
||||
on: {
|
||||
NEXT_PAGE: {
|
||||
target: '.loading',
|
||||
guard: ({ context }) => context.currentPage < context.totalPages,
|
||||
actions: assign({
|
||||
currentPage: ({ context }) => context.currentPage + 1,
|
||||
}),
|
||||
},
|
||||
PREV_PAGE: {
|
||||
target: '.loading',
|
||||
guard: ({ context }) => context.currentPage > 1,
|
||||
actions: assign({
|
||||
currentPage: ({ context }) => context.currentPage - 1,
|
||||
}),
|
||||
},
|
||||
GO_TO_PAGE: {
|
||||
target: '.loading',
|
||||
guard: ({ context, event }) =>
|
||||
event.page > 0 && event.page <= context.totalPages,
|
||||
actions: assign({
|
||||
currentPage: ({ event }) => event.page,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Wizard/Stepper Pattern
|
||||
|
||||
```typescript
|
||||
const wizardMachine = createMachine({
|
||||
initial: 'step1',
|
||||
context: {
|
||||
step1Data: null,
|
||||
step2Data: null,
|
||||
step3Data: null,
|
||||
},
|
||||
states: {
|
||||
step1: {
|
||||
initial: 'editing',
|
||||
states: {
|
||||
editing: {
|
||||
on: {
|
||||
SAVE: {
|
||||
target: 'validated',
|
||||
actions: assign({
|
||||
step1Data: ({ event }) => event.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
validated: {
|
||||
type: 'final',
|
||||
},
|
||||
},
|
||||
onDone: {
|
||||
target: 'step2',
|
||||
},
|
||||
},
|
||||
step2: {
|
||||
initial: 'editing',
|
||||
states: {
|
||||
editing: {
|
||||
on: {
|
||||
SAVE: {
|
||||
target: 'validated',
|
||||
actions: assign({
|
||||
step2Data: ({ event }) => event.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
validated: {
|
||||
type: 'final',
|
||||
},
|
||||
},
|
||||
on: {
|
||||
BACK: 'step1',
|
||||
},
|
||||
onDone: {
|
||||
target: 'step3',
|
||||
},
|
||||
},
|
||||
step3: {
|
||||
initial: 'editing',
|
||||
states: {
|
||||
editing: {
|
||||
on: {
|
||||
SAVE: {
|
||||
target: 'validated',
|
||||
actions: assign({
|
||||
step3Data: ({ event }) => event.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
validated: {
|
||||
type: 'final',
|
||||
},
|
||||
},
|
||||
on: {
|
||||
BACK: 'step2',
|
||||
},
|
||||
onDone: {
|
||||
target: 'review',
|
||||
},
|
||||
},
|
||||
review: {
|
||||
on: {
|
||||
EDIT_STEP1: 'step1',
|
||||
EDIT_STEP2: 'step2',
|
||||
EDIT_STEP3: 'step3',
|
||||
SUBMIT: 'submitting',
|
||||
},
|
||||
},
|
||||
submitting: {
|
||||
invoke: {
|
||||
src: 'submitWizard',
|
||||
input: ({ context }) => ({
|
||||
step1: context.step1Data,
|
||||
step2: context.step2Data,
|
||||
step3: context.step3Data,
|
||||
}),
|
||||
onDone: {
|
||||
target: 'complete',
|
||||
},
|
||||
onError: {
|
||||
target: 'review',
|
||||
actions: 'showError',
|
||||
},
|
||||
},
|
||||
},
|
||||
complete: {
|
||||
type: 'final',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Parallel States Pattern
|
||||
|
||||
### Upload/Download Manager
|
||||
|
||||
```typescript
|
||||
const fileManagerMachine = createMachine({
|
||||
type: 'parallel',
|
||||
context: {
|
||||
uploads: [],
|
||||
downloads: [],
|
||||
},
|
||||
states: {
|
||||
upload: {
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
START_UPLOAD: 'uploading',
|
||||
},
|
||||
},
|
||||
uploading: {
|
||||
invoke: {
|
||||
src: 'uploadFiles',
|
||||
onDone: {
|
||||
target: 'idle',
|
||||
actions: 'addUploadedFiles',
|
||||
},
|
||||
onError: {
|
||||
target: 'uploadError',
|
||||
},
|
||||
},
|
||||
},
|
||||
uploadError: {
|
||||
on: {
|
||||
RETRY_UPLOAD: 'uploading',
|
||||
CANCEL_UPLOAD: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
download: {
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
START_DOWNLOAD: 'downloading',
|
||||
},
|
||||
},
|
||||
downloading: {
|
||||
invoke: {
|
||||
src: 'downloadFiles',
|
||||
onDone: {
|
||||
target: 'idle',
|
||||
actions: 'addDownloadedFiles',
|
||||
},
|
||||
onError: {
|
||||
target: 'downloadError',
|
||||
},
|
||||
},
|
||||
},
|
||||
downloadError: {
|
||||
on: {
|
||||
RETRY_DOWNLOAD: 'downloading',
|
||||
CANCEL_DOWNLOAD: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## History States Pattern
|
||||
|
||||
### Editor with History
|
||||
|
||||
```typescript
|
||||
const editorMachine = createMachine({
|
||||
initial: 'editing',
|
||||
context: {
|
||||
content: '',
|
||||
mode: 'text',
|
||||
},
|
||||
states: {
|
||||
editing: {
|
||||
initial: 'text',
|
||||
states: {
|
||||
text: {
|
||||
on: {
|
||||
SWITCH_TO_VISUAL: 'visual',
|
||||
},
|
||||
},
|
||||
visual: {
|
||||
on: {
|
||||
SWITCH_TO_TEXT: 'text',
|
||||
OPEN_SETTINGS: '#editor.settings',
|
||||
},
|
||||
},
|
||||
history: {
|
||||
type: 'history',
|
||||
history: 'shallow',
|
||||
},
|
||||
},
|
||||
on: {
|
||||
SAVE: 'saving',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
on: {
|
||||
CLOSE: '#editor.editing.history', // Return to previous state
|
||||
APPLY: {
|
||||
target: '#editor.editing.history',
|
||||
actions: 'applySettings',
|
||||
},
|
||||
},
|
||||
},
|
||||
saving: {
|
||||
invoke: {
|
||||
src: 'saveContent',
|
||||
onDone: {
|
||||
target: 'editing',
|
||||
},
|
||||
onError: {
|
||||
target: 'editing',
|
||||
actions: 'showSaveError',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Debouncing Pattern
|
||||
|
||||
### Search with Debounce
|
||||
|
||||
```typescript
|
||||
const searchMachine = createMachine({
|
||||
initial: 'idle',
|
||||
context: {
|
||||
query: '',
|
||||
results: [],
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
SEARCH: {
|
||||
target: 'debouncing',
|
||||
actions: assign({
|
||||
query: ({ event }) => event.query,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
debouncing: {
|
||||
after: {
|
||||
300: 'searching', // 300ms debounce
|
||||
},
|
||||
on: {
|
||||
SEARCH: {
|
||||
target: 'debouncing',
|
||||
actions: assign({
|
||||
query: ({ event }) => event.query,
|
||||
}),
|
||||
reenter: true, // Reset the timer
|
||||
},
|
||||
},
|
||||
},
|
||||
searching: {
|
||||
invoke: {
|
||||
src: 'performSearch',
|
||||
input: ({ context }) => ({ query: context.query }),
|
||||
onDone: {
|
||||
target: 'idle',
|
||||
actions: assign({
|
||||
results: ({ event }) => event.output,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'idle',
|
||||
actions: 'logError',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Queue Processing Pattern
|
||||
|
||||
```typescript
|
||||
const queueMachine = createMachine({
|
||||
context: {
|
||||
queue: [],
|
||||
currentItem: null,
|
||||
processed: [],
|
||||
failed: [],
|
||||
},
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
always: [
|
||||
{
|
||||
target: 'processing',
|
||||
guard: ({ context }) => context.queue.length > 0,
|
||||
},
|
||||
],
|
||||
on: {
|
||||
ADD_TO_QUEUE: {
|
||||
actions: assign({
|
||||
queue: ({ context, event }) => [...context.queue, event.item],
|
||||
}),
|
||||
target: 'processing',
|
||||
},
|
||||
},
|
||||
},
|
||||
processing: {
|
||||
entry: assign({
|
||||
currentItem: ({ context }) => context.queue[0],
|
||||
queue: ({ context }) => context.queue.slice(1),
|
||||
}),
|
||||
invoke: {
|
||||
src: 'processItem',
|
||||
input: ({ context }) => context.currentItem,
|
||||
onDone: {
|
||||
target: 'idle',
|
||||
actions: assign({
|
||||
processed: ({ context, event }) => [
|
||||
...context.processed,
|
||||
{ item: context.currentItem, result: event.output },
|
||||
],
|
||||
currentItem: null,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'idle',
|
||||
actions: assign({
|
||||
failed: ({ context, event }) => [
|
||||
...context.failed,
|
||||
{ item: context.currentItem, error: event.error },
|
||||
],
|
||||
currentItem: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
on: {
|
||||
CLEAR_QUEUE: {
|
||||
actions: assign({
|
||||
queue: [],
|
||||
processed: [],
|
||||
failed: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Modal/Dialog Pattern
|
||||
|
||||
```typescript
|
||||
const modalMachine = createMachine({
|
||||
initial: 'closed',
|
||||
context: {
|
||||
data: null,
|
||||
result: null,
|
||||
},
|
||||
states: {
|
||||
closed: {
|
||||
on: {
|
||||
OPEN: {
|
||||
target: 'open',
|
||||
actions: assign({
|
||||
data: ({ event }) => event.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
open: {
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
CONFIRM: {
|
||||
target: 'confirming',
|
||||
},
|
||||
},
|
||||
},
|
||||
confirming: {
|
||||
invoke: {
|
||||
src: 'handleConfirm',
|
||||
input: ({ context }) => context.data,
|
||||
onDone: {
|
||||
actions: [
|
||||
assign({
|
||||
result: ({ event }) => event.output,
|
||||
}),
|
||||
emit({ type: 'MODAL_CONFIRMED' }),
|
||||
],
|
||||
target: '#modal.closed',
|
||||
},
|
||||
onError: {
|
||||
target: 'idle',
|
||||
actions: 'showError',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
on: {
|
||||
CANCEL: {
|
||||
target: 'closed',
|
||||
actions: [
|
||||
assign({ data: null, result: null }),
|
||||
emit({ type: 'MODAL_CANCELLED' }),
|
||||
],
|
||||
},
|
||||
CLOSE: {
|
||||
target: 'closed',
|
||||
actions: assign({ data: null, result: null }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Connection Management Pattern
|
||||
|
||||
```typescript
|
||||
const connectionMachine = createMachine({
|
||||
initial: 'disconnected',
|
||||
context: {
|
||||
retries: 0,
|
||||
maxRetries: 5,
|
||||
socket: null,
|
||||
},
|
||||
states: {
|
||||
disconnected: {
|
||||
on: {
|
||||
CONNECT: 'connecting',
|
||||
},
|
||||
},
|
||||
connecting: {
|
||||
invoke: {
|
||||
src: 'createConnection',
|
||||
onDone: {
|
||||
target: 'connected',
|
||||
actions: assign({
|
||||
socket: ({ event }) => event.output,
|
||||
retries: 0,
|
||||
}),
|
||||
},
|
||||
onError: [
|
||||
{
|
||||
target: 'reconnecting',
|
||||
guard: ({ context }) => context.retries < context.maxRetries,
|
||||
actions: assign({
|
||||
retries: ({ context }) => context.retries + 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: 'failed',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
connected: {
|
||||
invoke: {
|
||||
src: 'monitorConnection',
|
||||
onError: {
|
||||
target: 'reconnecting',
|
||||
},
|
||||
},
|
||||
on: {
|
||||
DISCONNECT: {
|
||||
target: 'disconnected',
|
||||
actions: 'closeConnection',
|
||||
},
|
||||
CONNECTION_LOST: 'reconnecting',
|
||||
},
|
||||
},
|
||||
reconnecting: {
|
||||
after: {
|
||||
// Exponential backoff
|
||||
[({ context }) => Math.min(1000 * Math.pow(2, context.retries), 30000)]:
|
||||
'connecting',
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
on: {
|
||||
RETRY: {
|
||||
target: 'connecting',
|
||||
actions: assign({ retries: 0 }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
491
skills/xstate-v5/references/testing.md
Normal file
491
skills/xstate-v5/references/testing.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# XState v5 Testing Reference (Backend Focus)
|
||||
|
||||
This reference covers testing XState v5 actors and state machines for **backend applications**. For comprehensive actor testing patterns, see the [xstate-audition skill](../../xstate-audition/SKILL.md).
|
||||
|
||||
## Testing Strategy Overview
|
||||
|
||||
XState v5 testing for backend applications focuses on:
|
||||
|
||||
- Actor lifecycle and behavior testing
|
||||
- State transition verification
|
||||
- Event handling and inter-actor communication
|
||||
- Asynchronous operation testing
|
||||
- Error handling and recovery
|
||||
|
||||
## Recommended Testing Library: xstate-audition
|
||||
|
||||
For comprehensive actor testing, use **[xstate-audition](https://boneskull.github.io/xstate-audition/)**, which provides:
|
||||
|
||||
- Dependency-free XState v5 actor testing
|
||||
- Async-first testing utilities
|
||||
- Comprehensive actor lifecycle testing
|
||||
- Built-in timeout handling
|
||||
- Curried functions for test reusability
|
||||
|
||||
See the [xstate-audition skill documentation](../../xstate-audition/SKILL.md) for detailed usage patterns.
|
||||
|
||||
## Basic Unit Testing
|
||||
|
||||
While xstate-audition excels at actor testing, you may need to unit test individual pieces:
|
||||
|
||||
### Testing Guards in Isolation
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
// Guard function
|
||||
const isValidThreshold = ({ context }: { context: { value: number } }) =>
|
||||
context.value > 10;
|
||||
|
||||
describe('guards', () => {
|
||||
test('isValidThreshold returns true for values > 10', () => {
|
||||
expect(isValidThreshold({ context: { value: 15 } })).toBe(true);
|
||||
expect(isValidThreshold({ context: { value: 5 } })).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Action Functions
|
||||
|
||||
```typescript
|
||||
import { vi, describe, test, expect } from 'vitest';
|
||||
|
||||
// Action function
|
||||
const logMessage = ({ context, event }: any) => {
|
||||
console.log(`Event: ${event.type}, Count: ${context.count}`);
|
||||
};
|
||||
|
||||
test('logMessage action logs correctly', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
logMessage({
|
||||
context: { count: 5 },
|
||||
event: { type: 'TEST_EVENT' },
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Event: TEST_EVENT, Count: 5');
|
||||
});
|
||||
```
|
||||
|
||||
## Backend-Specific Testing Patterns
|
||||
|
||||
### Testing Database Transaction States
|
||||
|
||||
```typescript
|
||||
import { createActor } from 'xstate';
|
||||
import { runUntilDone, waitForSnapshot } from 'xstate-audition';
|
||||
|
||||
const dbTransactionMachine = createMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
on: { BEGIN: 'transaction' },
|
||||
},
|
||||
transaction: {
|
||||
initial: 'processing',
|
||||
states: {
|
||||
processing: {
|
||||
invoke: {
|
||||
src: 'executeQuery',
|
||||
onDone: 'committing',
|
||||
onError: 'rollingBack',
|
||||
},
|
||||
},
|
||||
committing: {
|
||||
invoke: {
|
||||
src: 'commitTransaction',
|
||||
onDone: '#db.success',
|
||||
onError: '.rollingBack',
|
||||
},
|
||||
},
|
||||
rollingBack: {
|
||||
invoke: {
|
||||
src: 'rollbackTransaction',
|
||||
onDone: '#db.failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
success: { type: 'final' },
|
||||
failed: { type: 'final' },
|
||||
},
|
||||
});
|
||||
|
||||
test('successful database transaction', async () => {
|
||||
const actor = createActor(dbTransactionMachine);
|
||||
const promise = waitForSnapshot(actor, (s) => s.matches('success'));
|
||||
|
||||
actor.send({ type: 'BEGIN' });
|
||||
await promise;
|
||||
|
||||
expect(actor.getSnapshot().value).toBe('success');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Message Queue Processing
|
||||
|
||||
```typescript
|
||||
const queueProcessorMachine = createMachine({
|
||||
context: {
|
||||
messages: [],
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
},
|
||||
initial: 'polling',
|
||||
states: {
|
||||
polling: {
|
||||
invoke: {
|
||||
src: 'pollQueue',
|
||||
onDone: {
|
||||
target: 'processing',
|
||||
actions: assign({
|
||||
messages: ({ event }) => event.output,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
processing: {
|
||||
always: [
|
||||
{
|
||||
target: 'idle',
|
||||
guard: ({ context }) => context.messages.length === 0,
|
||||
},
|
||||
{
|
||||
target: 'processingMessage',
|
||||
},
|
||||
],
|
||||
},
|
||||
processingMessage: {
|
||||
invoke: {
|
||||
src: 'processMessage',
|
||||
input: ({ context }) => context.messages[0],
|
||||
onDone: {
|
||||
target: 'processing',
|
||||
actions: assign({
|
||||
messages: ({ context }) => context.messages.slice(1),
|
||||
processed: ({ context }) => context.processed + 1,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'processing',
|
||||
actions: assign({
|
||||
messages: ({ context }) => context.messages.slice(1),
|
||||
failed: ({ context }) => context.failed + 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
idle: {
|
||||
after: {
|
||||
5000: 'polling',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test('processes queue messages', async () => {
|
||||
const actor = createActor(queueProcessorMachine);
|
||||
|
||||
await waitForSnapshot(actor, (snapshot) => snapshot.context.processed >= 3);
|
||||
|
||||
expect(actor.getSnapshot().context.failed).toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing API Rate Limiting States
|
||||
|
||||
```typescript
|
||||
const rateLimiterMachine = createMachine({
|
||||
context: {
|
||||
requestCount: 0,
|
||||
resetTime: null,
|
||||
},
|
||||
initial: 'ready',
|
||||
states: {
|
||||
ready: {
|
||||
on: {
|
||||
REQUEST: [
|
||||
{
|
||||
target: 'throttled',
|
||||
guard: ({ context }) => context.requestCount >= 100,
|
||||
actions: assign({
|
||||
resetTime: () => Date.now() + 60000,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: 'processing',
|
||||
actions: assign({
|
||||
requestCount: ({ context }) => context.requestCount + 1,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
processing: {
|
||||
invoke: {
|
||||
src: 'handleRequest',
|
||||
onDone: 'ready',
|
||||
onError: 'ready',
|
||||
},
|
||||
},
|
||||
throttled: {
|
||||
after: {
|
||||
60000: {
|
||||
target: 'ready',
|
||||
actions: assign({
|
||||
requestCount: 0,
|
||||
resetTime: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test('enforces rate limits', async () => {
|
||||
const actor = createActor(rateLimiterMachine);
|
||||
|
||||
// Send 100 requests
|
||||
for (let i = 0; i < 100; i++) {
|
||||
actor.send({ type: 'REQUEST' });
|
||||
}
|
||||
|
||||
// 101st request should be throttled
|
||||
actor.send({ type: 'REQUEST' });
|
||||
|
||||
await waitForSnapshot(actor, (s) => s.matches('throttled'));
|
||||
expect(actor.getSnapshot().context.resetTime).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing with Mock Services
|
||||
|
||||
### Mocking External Services
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
import { fromPromise } from 'xstate';
|
||||
|
||||
test('handles service failures gracefully', async () => {
|
||||
const mockService = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Service unavailable'));
|
||||
|
||||
const machine = setup({
|
||||
actors: {
|
||||
externalService: fromPromise(mockService),
|
||||
},
|
||||
}).createMachine({
|
||||
initial: 'calling',
|
||||
states: {
|
||||
calling: {
|
||||
invoke: {
|
||||
src: 'externalService',
|
||||
onError: {
|
||||
target: 'fallback',
|
||||
actions: assign({
|
||||
error: ({ event }) => event.error.message,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
fallback: {
|
||||
type: 'final',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const actor = createActor(machine);
|
||||
const result = await runUntilDone(actor);
|
||||
|
||||
expect(actor.getSnapshot().context.error).toBe('Service unavailable');
|
||||
expect(mockService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Async Behavior
|
||||
|
||||
### Using waitFor from xstate
|
||||
|
||||
```typescript
|
||||
import { waitFor } from 'xstate';
|
||||
|
||||
test('waits for async completion', async () => {
|
||||
const actor = createActor(asyncMachine);
|
||||
actor.start();
|
||||
|
||||
actor.send({ type: 'START_ASYNC' });
|
||||
|
||||
const snapshot = await waitFor(
|
||||
actor,
|
||||
(snapshot) => snapshot.context.isComplete === true,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
expect(snapshot.context.result).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing with Fake Timers
|
||||
|
||||
```typescript
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('delayed transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('transitions after delay', () => {
|
||||
const actor = createActor(delayedMachine);
|
||||
actor.start();
|
||||
|
||||
expect(actor.getSnapshot().value).toBe('waiting');
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
expect(actor.getSnapshot().value).toBe('ready');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Actor Communication
|
||||
|
||||
### Testing Parent-Child Actors
|
||||
|
||||
```typescript
|
||||
import { spawn, sendTo } from 'xstate';
|
||||
import { runUntilSpawn, waitForSnapshot } from 'xstate-audition';
|
||||
|
||||
test('parent spawns and communicates with child', async () => {
|
||||
const parentMachine = createMachine({
|
||||
initial: 'idle',
|
||||
context: {
|
||||
childResponse: null,
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
START: {
|
||||
actions: spawn(childMachine, { id: 'child' }),
|
||||
target: 'waiting',
|
||||
},
|
||||
},
|
||||
},
|
||||
waiting: {
|
||||
on: {
|
||||
CHILD_RESPONSE: {
|
||||
actions: assign({
|
||||
childResponse: ({ event }) => event.data,
|
||||
}),
|
||||
target: 'done',
|
||||
},
|
||||
},
|
||||
entry: sendTo('child', { type: 'PROCESS' }),
|
||||
},
|
||||
done: { type: 'final' },
|
||||
},
|
||||
});
|
||||
|
||||
const actor = createActor(parentMachine);
|
||||
const childRef = await runUntilSpawn(actor, 'child');
|
||||
|
||||
await waitForSnapshot(actor, (s) => s.matches('done'));
|
||||
|
||||
expect(actor.getSnapshot().context.childResponse).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
```typescript
|
||||
test('handles high throughput', async () => {
|
||||
const start = performance.now();
|
||||
const actor = createActor(highThroughputMachine);
|
||||
|
||||
actor.start();
|
||||
|
||||
// Send many events rapidly
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
actor.send({ type: 'PROCESS', data: i });
|
||||
}
|
||||
|
||||
await waitFor(actor, (s) => s.context.processedCount === 10000);
|
||||
|
||||
const duration = performance.now() - start;
|
||||
expect(duration).toBeLessThan(5000); // Should process within 5 seconds
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling Tests
|
||||
|
||||
```typescript
|
||||
test('recovers from errors with retry', async () => {
|
||||
let attempts = 0;
|
||||
const mockService = vi.fn().mockImplementation(() => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary failure');
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const retryMachine = createMachine({
|
||||
context: { retries: 0, maxRetries: 3 },
|
||||
initial: 'attempting',
|
||||
states: {
|
||||
attempting: {
|
||||
invoke: {
|
||||
src: fromPromise(mockService),
|
||||
onDone: 'success',
|
||||
onError: [
|
||||
{
|
||||
target: 'attempting',
|
||||
guard: ({ context }) => context.retries < context.maxRetries,
|
||||
actions: assign({
|
||||
retries: ({ context }) => context.retries + 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: 'failed',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
success: { type: 'final' },
|
||||
failed: { type: 'final' },
|
||||
},
|
||||
});
|
||||
|
||||
const actor = createActor(retryMachine);
|
||||
await runUntilDone(actor);
|
||||
|
||||
expect(actor.getSnapshot().value).toBe('success');
|
||||
expect(mockService).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
```
|
||||
|
||||
## Test Organization Best Practices
|
||||
|
||||
1. **Separate unit tests from integration tests** - Test guards and actions separately from full actor behavior
|
||||
2. **Use xstate-audition for actor tests** - Leverage its powerful async utilities
|
||||
3. **Mock external dependencies** - Database, APIs, message queues
|
||||
4. **Test error paths explicitly** - Ensure graceful degradation
|
||||
5. **Use descriptive test names** - Clearly indicate what behavior is being tested
|
||||
6. **Keep tests focused** - One behavior per test
|
||||
7. **Use fake timers for time-dependent logic** - Control time progression explicitly
|
||||
|
||||
## Summary
|
||||
|
||||
For backend XState v5 testing:
|
||||
|
||||
- Use **[xstate-audition](../../xstate-audition/SKILL.md)** for comprehensive actor testing
|
||||
- Focus on actor behavior, not UI interactions
|
||||
- Test database transactions, message processing, and API interactions
|
||||
- Mock external services appropriately
|
||||
- Handle async behavior with proper utilities
|
||||
- Ensure error recovery and retry logic works correctly
|
||||
626
skills/xstate-v5/references/typescript.md
Normal file
626
skills/xstate-v5/references/typescript.md
Normal file
@@ -0,0 +1,626 @@
|
||||
# XState v5 TypeScript Reference
|
||||
|
||||
## Requirements
|
||||
|
||||
- **TypeScript 5.0 or greater** is required for XState v5
|
||||
- Enable `strictNullChecks: true` in tsconfig.json (strongly recommended)
|
||||
- Set `skipLibCheck: true` for better performance
|
||||
|
||||
## Setup Pattern (Recommended)
|
||||
|
||||
The `setup()` function is the primary way to achieve strong typing in XState v5.
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import { setup } from 'xstate';
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as {
|
||||
count: number;
|
||||
user: User | null;
|
||||
},
|
||||
events: {} as
|
||||
| { type: 'INCREMENT' }
|
||||
| { type: 'DECREMENT' }
|
||||
| { type: 'SET_USER'; user: User },
|
||||
input: {} as {
|
||||
initialCount?: number;
|
||||
},
|
||||
output: {} as {
|
||||
finalCount: number;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
incrementCount: assign({
|
||||
count: ({ context }) => context.count + 1,
|
||||
}),
|
||||
setUser: assign({
|
||||
user: ({ event }) => {
|
||||
// TypeScript knows event has 'user' property here
|
||||
assertEvent(event, 'SET_USER');
|
||||
return event.user;
|
||||
},
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
isPositive: ({ context }) => context.count > 0,
|
||||
hasUser: ({ context }) => context.user !== null,
|
||||
},
|
||||
actors: {
|
||||
fetchUser: fromPromise(async ({ input }: { input: { id: string } }) => {
|
||||
const response = await fetch(`/api/users/${input.id}`);
|
||||
return response.json() as Promise<User>;
|
||||
}),
|
||||
},
|
||||
delays: {
|
||||
RETRY_DELAY: ({ context }) => context.count * 1000,
|
||||
},
|
||||
}).createMachine({
|
||||
// Machine configuration with full type inference
|
||||
id: 'typedMachine',
|
||||
initial: 'idle',
|
||||
context: ({ input }) => ({
|
||||
count: input?.initialCount ?? 0,
|
||||
user: null,
|
||||
}),
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
INCREMENT: {
|
||||
actions: 'incrementCount',
|
||||
},
|
||||
SET_USER: {
|
||||
actions: 'setUser',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Type Inference
|
||||
|
||||
### Context Type Inference
|
||||
|
||||
```typescript
|
||||
// Context is fully typed in actions, guards, etc.
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as {
|
||||
items: string[];
|
||||
selectedIndex: number;
|
||||
},
|
||||
},
|
||||
}).createMachine({
|
||||
context: {
|
||||
items: [],
|
||||
selectedIndex: -1,
|
||||
},
|
||||
// TypeScript knows context shape everywhere
|
||||
entry: ({ context }) => {
|
||||
console.log(context.items); // string[]
|
||||
console.log(context.selectedIndex); // number
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Event Type Inference
|
||||
|
||||
```typescript
|
||||
const machine = setup({
|
||||
types: {
|
||||
events: {} as
|
||||
| { type: 'ADD_ITEM'; item: string }
|
||||
| { type: 'REMOVE_ITEM'; index: number }
|
||||
| { type: 'CLEAR_ALL' },
|
||||
},
|
||||
}).createMachine({
|
||||
on: {
|
||||
ADD_ITEM: {
|
||||
actions: ({ event }) => {
|
||||
// TypeScript knows event.item exists and is string
|
||||
console.log(event.item.toUpperCase());
|
||||
},
|
||||
},
|
||||
REMOVE_ITEM: {
|
||||
actions: ({ event }) => {
|
||||
// TypeScript knows event.index exists and is number
|
||||
console.log(event.index + 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Dynamic Parameters
|
||||
|
||||
### Typed Action Parameters
|
||||
|
||||
```typescript
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as { message: string },
|
||||
},
|
||||
actions: {
|
||||
log: (
|
||||
{ context },
|
||||
params: { prefix: string; level?: 'info' | 'warn' | 'error' },
|
||||
) => {
|
||||
const level = params.level || 'info';
|
||||
console[level](`${params.prefix}: ${context.message}`);
|
||||
},
|
||||
},
|
||||
}).createMachine({
|
||||
entry: {
|
||||
type: 'log',
|
||||
params: { prefix: 'Starting', level: 'info' },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Typed Guard Parameters
|
||||
|
||||
```typescript
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as { value: number },
|
||||
},
|
||||
guards: {
|
||||
isGreaterThan: ({ context }, params: { threshold: number }) =>
|
||||
context.value > params.threshold,
|
||||
isInRange: ({ context }, params: { min: number; max: number }) =>
|
||||
context.value >= params.min && context.value <= params.max,
|
||||
},
|
||||
}).createMachine({
|
||||
on: {
|
||||
CHECK: [
|
||||
{
|
||||
target: 'high',
|
||||
guard: {
|
||||
type: 'isGreaterThan',
|
||||
params: { threshold: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
target: 'medium',
|
||||
guard: {
|
||||
type: 'isInRange',
|
||||
params: { min: 50, max: 100 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Event Assertion
|
||||
|
||||
Use `assertEvent()` to narrow event types:
|
||||
|
||||
```typescript
|
||||
import { assertEvent } from 'xstate';
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
events: {} as
|
||||
| { type: 'SUBMIT'; data: FormData }
|
||||
| { type: 'CANCEL' }
|
||||
| { type: 'RESET'; fields?: string[] },
|
||||
},
|
||||
}).createMachine({
|
||||
on: {
|
||||
'*': {
|
||||
actions: ({ event }) => {
|
||||
// Assert single event type
|
||||
assertEvent(event, 'SUBMIT');
|
||||
console.log(event.data); // TypeScript knows data exists
|
||||
|
||||
// Assert multiple event types
|
||||
assertEvent(event, ['SUBMIT', 'RESET']);
|
||||
if (event.type === 'SUBMIT') {
|
||||
console.log(event.data);
|
||||
} else {
|
||||
console.log(event.fields);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Type Helper Utilities
|
||||
|
||||
### ActorRefFrom
|
||||
|
||||
Extract actor reference type from machine or logic:
|
||||
|
||||
```typescript
|
||||
import { ActorRefFrom } from 'xstate';
|
||||
|
||||
const machine = createMachine({
|
||||
/* ... */
|
||||
});
|
||||
|
||||
type MachineActorRef = ActorRefFrom<typeof machine>;
|
||||
|
||||
function handleActor(actor: MachineActorRef) {
|
||||
actor.send({ type: 'SOME_EVENT' });
|
||||
const snapshot = actor.getSnapshot();
|
||||
}
|
||||
```
|
||||
|
||||
### SnapshotFrom
|
||||
|
||||
Extract snapshot type:
|
||||
|
||||
```typescript
|
||||
import { SnapshotFrom } from 'xstate';
|
||||
|
||||
type MachineSnapshot = SnapshotFrom<typeof machine>;
|
||||
|
||||
function processSnapshot(snapshot: MachineSnapshot) {
|
||||
if (snapshot.matches('loading')) {
|
||||
// Handle loading state
|
||||
}
|
||||
console.log(snapshot.context);
|
||||
}
|
||||
```
|
||||
|
||||
### EventFromLogic
|
||||
|
||||
Extract event union type:
|
||||
|
||||
```typescript
|
||||
import { EventFromLogic } from 'xstate';
|
||||
|
||||
type MachineEvents = EventFromLogic<typeof machine>;
|
||||
|
||||
function createEvent(): MachineEvents {
|
||||
return { type: 'INCREMENT' };
|
||||
}
|
||||
```
|
||||
|
||||
### StateValueFrom
|
||||
|
||||
Extract state value type:
|
||||
|
||||
```typescript
|
||||
import { StateValueFrom } from 'xstate';
|
||||
|
||||
type MachineStateValue = StateValueFrom<typeof machine>;
|
||||
|
||||
function isInState(value: MachineStateValue) {
|
||||
// Type-safe state value checking
|
||||
}
|
||||
```
|
||||
|
||||
### ContextFrom
|
||||
|
||||
Extract context type:
|
||||
|
||||
```typescript
|
||||
import { ContextFrom } from 'xstate';
|
||||
|
||||
type MachineContext = ContextFrom<typeof machine>;
|
||||
|
||||
function processContext(context: MachineContext) {
|
||||
// Access typed context properties
|
||||
}
|
||||
```
|
||||
|
||||
## Actor Types
|
||||
|
||||
### Typed Promise Actors
|
||||
|
||||
```typescript
|
||||
interface UserData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const fetchUserLogic = fromPromise(
|
||||
async ({ input }: { input: { userId: string } }): Promise<UserData> => {
|
||||
const response = await fetch(`/api/users/${input.userId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch user');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
);
|
||||
|
||||
// Use in machine
|
||||
const machine = setup({
|
||||
actors: {
|
||||
fetchUser: fetchUserLogic,
|
||||
},
|
||||
}).createMachine({
|
||||
invoke: {
|
||||
src: 'fetchUser',
|
||||
input: ({ context }) => ({ userId: context.userId }),
|
||||
onDone: {
|
||||
actions: assign({
|
||||
user: ({ event }) => event.output, // Typed as UserData
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Typed Callback Actors
|
||||
|
||||
```typescript
|
||||
interface WebSocketMessage {
|
||||
type: 'message' | 'error' | 'close';
|
||||
data: any;
|
||||
}
|
||||
|
||||
const websocketLogic = fromCallback<WebSocketMessage, { url: string }>(
|
||||
({ sendBack, receive, input }) => {
|
||||
const ws = new WebSocket(input.url);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
sendBack({ type: 'message', data: event.data });
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
sendBack({ type: 'error', data: error });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
sendBack({ type: 'close', data: null });
|
||||
};
|
||||
|
||||
receive((event) => {
|
||||
if (event.type === 'SEND') {
|
||||
ws.send(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
return () => ws.close();
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Generic Machines
|
||||
|
||||
### Creating Generic Machine Factories
|
||||
|
||||
```typescript
|
||||
function createCrudMachine<T extends { id: string }>() {
|
||||
return setup({
|
||||
types: {
|
||||
context: {} as {
|
||||
items: T[];
|
||||
selectedItem: T | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
},
|
||||
events: {} as
|
||||
| { type: 'FETCH' }
|
||||
| { type: 'CREATE'; item: Omit<T, 'id'> }
|
||||
| { type: 'UPDATE'; id: string; updates: Partial<T> }
|
||||
| { type: 'DELETE'; id: string }
|
||||
| { type: 'SELECT'; item: T },
|
||||
},
|
||||
}).createMachine({
|
||||
initial: 'idle',
|
||||
context: {
|
||||
items: [],
|
||||
selectedItem: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
FETCH: 'fetching',
|
||||
CREATE: 'creating',
|
||||
SELECT: {
|
||||
actions: assign({
|
||||
selectedItem: ({ event }) => event.item,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
fetching: {
|
||||
// Implementation
|
||||
},
|
||||
creating: {
|
||||
// Implementation
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Use with specific type
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const userMachine = createCrudMachine<User>();
|
||||
```
|
||||
|
||||
## Discriminated Unions
|
||||
|
||||
### State-based Discrimination
|
||||
|
||||
```typescript
|
||||
type MachineState =
|
||||
| {
|
||||
value: 'idle';
|
||||
context: { data: null; error: null };
|
||||
}
|
||||
| {
|
||||
value: 'loading';
|
||||
context: { data: null; error: null };
|
||||
}
|
||||
| {
|
||||
value: 'success';
|
||||
context: { data: string; error: null };
|
||||
}
|
||||
| {
|
||||
value: 'failure';
|
||||
context: { data: null; error: Error };
|
||||
};
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as MachineState['context'],
|
||||
},
|
||||
}).createMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {},
|
||||
loading: {},
|
||||
success: {},
|
||||
failure: {},
|
||||
},
|
||||
});
|
||||
|
||||
// Type-safe state checking
|
||||
function handleState(state: SnapshotFrom<typeof machine>) {
|
||||
if (state.matches('success')) {
|
||||
// TypeScript should know data is string here
|
||||
console.log(state.context.data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common TypeScript Patterns
|
||||
|
||||
### Exhaustive Event Handling
|
||||
|
||||
```typescript
|
||||
const machine = setup({
|
||||
types: {
|
||||
events: {} as { type: 'A' } | { type: 'B' } | { type: 'C' },
|
||||
},
|
||||
}).createMachine({
|
||||
on: {
|
||||
'*': {
|
||||
actions: ({ event }) => {
|
||||
switch (event.type) {
|
||||
case 'A':
|
||||
// Handle A
|
||||
break;
|
||||
case 'B':
|
||||
// Handle B
|
||||
break;
|
||||
case 'C':
|
||||
// Handle C
|
||||
break;
|
||||
default:
|
||||
// TypeScript ensures this is never reached
|
||||
const exhaustive: never = event;
|
||||
throw new Error(`Unhandled event: ${exhaustive}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Branded Types for IDs
|
||||
|
||||
```typescript
|
||||
type UserId = string & { __brand: 'UserId' };
|
||||
type PostId = string & { __brand: 'PostId' };
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as {
|
||||
userId: UserId | null;
|
||||
postId: PostId | null;
|
||||
},
|
||||
events: {} as
|
||||
| { type: 'SET_USER'; id: UserId }
|
||||
| { type: 'SET_POST'; id: PostId },
|
||||
},
|
||||
}).createMachine({
|
||||
on: {
|
||||
SET_USER: {
|
||||
actions: assign({
|
||||
userId: ({ event }) => event.id, // Type-safe: only UserId accepted
|
||||
}),
|
||||
},
|
||||
SET_POST: {
|
||||
actions: assign({
|
||||
postId: ({ event }) => event.id, // Type-safe: only PostId accepted
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Const Assertions for Events
|
||||
|
||||
```typescript
|
||||
// Define events with const assertion for literal types
|
||||
const EVENTS = {
|
||||
INCREMENT: { type: 'INCREMENT' } as const,
|
||||
DECREMENT: { type: 'DECREMENT' } as const,
|
||||
RESET: { type: 'RESET' } as const,
|
||||
SET_VALUE: (value: number) => ({ type: 'SET_VALUE', value }) as const,
|
||||
} as const;
|
||||
|
||||
type MachineEvent = ReturnType<(typeof EVENTS)[keyof typeof EVENTS]>;
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
events: {} as MachineEvent,
|
||||
},
|
||||
}).createMachine({
|
||||
// Machine configuration
|
||||
});
|
||||
|
||||
// Usage
|
||||
actor.send(EVENTS.INCREMENT);
|
||||
actor.send(EVENTS.SET_VALUE(42));
|
||||
```
|
||||
|
||||
## Migration from v4 Types
|
||||
|
||||
### Replacing Typegen
|
||||
|
||||
v4 used typegen for type safety. In v5, use `setup()` instead:
|
||||
|
||||
```typescript
|
||||
// v4 with typegen
|
||||
import { createMachine } from 'xstate';
|
||||
import { typegen } from './machine.typegen';
|
||||
|
||||
const machine = createMachine({
|
||||
tsTypes: typegen,
|
||||
// ...
|
||||
});
|
||||
|
||||
// v5 with setup
|
||||
import { setup } from 'xstate';
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as ContextType,
|
||||
events: {} as EventsUnion,
|
||||
},
|
||||
}).createMachine({
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use setup()** for new machines
|
||||
2. **Define types first** in the setup configuration
|
||||
3. **Use const assertions** for literal types
|
||||
4. **Enable strict mode** in tsconfig.json
|
||||
5. **Use branded types** for domain-specific IDs
|
||||
6. **Leverage type helpers** (ActorRefFrom, SnapshotFrom, etc.)
|
||||
7. **Assert events** when needed with assertEvent()
|
||||
8. **Create generic factories** for reusable patterns
|
||||
9. **Type external actors** properly with input/output types
|
||||
10. **Use discriminated unions** for complex state variations
|
||||
Reference in New Issue
Block a user