Initial commit
This commit is contained in:
160
skills/xstate-v5/SKILL.md
Normal file
160
skills/xstate-v5/SKILL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
description: Expert guidance for implementing and analyzing XState v5 state machines with TypeScript support and best practices
|
||||
triggers:
|
||||
- xstate
|
||||
- state machine
|
||||
- statechart
|
||||
- createMachine
|
||||
- createActor
|
||||
- fsm implementation
|
||||
- actor model
|
||||
- state management xstate
|
||||
---
|
||||
|
||||
# XState v5 Expert Skill
|
||||
|
||||
You are an expert in XState v5, a JavaScript/TypeScript library for creating, interpreting, and executing finite state machines and statecharts using the actor model. Use this knowledge to help implement and analyze XState v5 code with precision and adherence to best practices.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### State Machines & Statecharts
|
||||
|
||||
XState implements event-driven programming through state machines and statecharts, providing predictable and robust logic handling. Always:
|
||||
|
||||
- Model application logic as explicit states and transitions
|
||||
- Use statecharts for complex hierarchical and parallel state management
|
||||
- Ensure every state transition is intentional and documented
|
||||
|
||||
### Actor Model
|
||||
|
||||
XState uses the actor model for distributed, concurrent computation:
|
||||
|
||||
- **State machine actors**: Primary actors created from state machines
|
||||
- **Promise actors**: Handle asynchronous operations
|
||||
- **Transition actors**: Manage pure transitions
|
||||
- **Callback actors**: Custom imperative logic
|
||||
- **Observable actors**: Stream-based actors
|
||||
|
||||
## Quick Start Example
|
||||
|
||||
```typescript
|
||||
import { setup, createActor, assign } from 'xstate';
|
||||
|
||||
const machine = setup({
|
||||
types: {
|
||||
context: {} as { count: number },
|
||||
events: {} as { type: 'INCREMENT' } | { type: 'DECREMENT' },
|
||||
},
|
||||
actions: {
|
||||
increment: assign({ count: ({ context }) => context.count + 1 }),
|
||||
decrement: assign({ count: ({ context }) => context.count - 1 }),
|
||||
},
|
||||
guards: {
|
||||
isPositive: ({ context }) => context.count > 0,
|
||||
},
|
||||
}).createMachine({
|
||||
id: 'counter',
|
||||
initial: 'active',
|
||||
context: { count: 0 },
|
||||
states: {
|
||||
active: {
|
||||
on: {
|
||||
INCREMENT: { actions: 'increment' },
|
||||
DECREMENT: {
|
||||
actions: 'decrement',
|
||||
guard: 'isPositive',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const actor = createActor(machine);
|
||||
actor.subscribe((snapshot) => console.log(snapshot.context));
|
||||
actor.start();
|
||||
actor.send({ type: 'INCREMENT' });
|
||||
```
|
||||
|
||||
## 📚 Reference Documentation
|
||||
|
||||
For detailed implementation guidance, consult the comprehensive reference documentation:
|
||||
|
||||
### [Core API Reference](./references/core-api.md)
|
||||
|
||||
Complete API documentation including:
|
||||
|
||||
- Machine creation (`createMachine`, `setup`)
|
||||
- Actor management and lifecycle
|
||||
- Actions, guards, and services
|
||||
- Utility functions and type helpers
|
||||
|
||||
### [Actors Reference](./references/actors.md)
|
||||
|
||||
Deep dive into the actor model:
|
||||
|
||||
- All actor types (state machine, promise, callback, transition, observable)
|
||||
- Actor communication and orchestration
|
||||
- Spawning vs invoking actors
|
||||
- Error handling and persistence
|
||||
|
||||
### [Common Patterns](./references/patterns.md)
|
||||
|
||||
Production-ready patterns and solutions:
|
||||
|
||||
- Loading states with retry logic
|
||||
- Form validation and submission
|
||||
- Authentication flows
|
||||
- Pagination, wizards, modals
|
||||
- Debouncing and queue processing
|
||||
|
||||
### [TypeScript Integration](./references/typescript.md)
|
||||
|
||||
Complete TypeScript usage guide:
|
||||
|
||||
- Setup pattern with strong typing
|
||||
- Type inference and helpers
|
||||
- Generic machine factories
|
||||
- Backend service types
|
||||
|
||||
### [Testing Strategies](./references/testing.md)
|
||||
|
||||
Backend testing approaches:
|
||||
|
||||
- Unit testing state machines
|
||||
- Using xstate-audition for actor testing
|
||||
- Mocking external services
|
||||
- Testing async backend operations
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use setup()** for better type inference and reusable logic
|
||||
2. **Name all actions and guards** for clarity and reusability
|
||||
3. **Use context for data, states for behavior**
|
||||
4. **Keep machines focused** - one machine per logical unit
|
||||
5. **Leverage TypeScript** for compile-time safety
|
||||
6. **Avoid inline functions** in machine definitions. Used named guards and actions.
|
||||
7. **Test with `xstate-audition`** for comprehensive coverage
|
||||
8. **Use Promise actors** for asynchronous operations
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Don't mutate context directly** - always use `assign`
|
||||
2. **Don't use side effects in guards** - guards should be pure
|
||||
3. **Don't overuse nested states** - flatten when possible
|
||||
4. **Don't ignore TypeScript errors** - they prevent runtime issues
|
||||
5. **Don't mix concerns** - separate UI from business logic
|
||||
6. **Don't use string events** when objects provide better typing
|
||||
7. **Don't forget error handling** in async operations
|
||||
8. **Don't use `setInterval`** in machine definitions. Use delays instead.
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. Use `enqueueActions()` for conditional actions instead of multiple transitions
|
||||
2. Minimize context updates
|
||||
3. Use lazy evaluation with function updaters
|
||||
4. Leverage memoization for expensive computations
|
||||
5. Split large machines into smaller actors
|
||||
|
||||
## Conclusion
|
||||
|
||||
Remember: XState excels at making complex logic predictable and maintainable. Always prioritize clarity and correctness over brevity.
|
||||
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