# Tooling and Ecosystem Standards for XState
This document outlines coding standards specific to the XState library, focusing on the tooling and ecosystem surrounding it. Adhering to these standards will promote code maintainability, readability, performance, and overall project health. These standards integrate modern Javascript/Typescript practices with XState features.
## 1. Development Environment Setup
### 1.1 Project Initialization
**Standard:** Utilize official Stately tooling, such as the Stately Editor and CLI, for project bootstrapping.
**Do This:** Use the Stately Editor for visual machine design and code generation, especially for complex state machines. For projects using code generation, integrate into a pre-commit hook, or other automated tooling.
"""bash
#Use the Stately Editor to design state machine and then export XState code
#Use the CLI tooling to validate state machine definitions (requires installation)
npx @statelyai/cli@latest typegen src/machines/*.ts
"""
**Don't Do This:** Manually create state machine definitions without utilizing supporting tooling. This significantly increases the risk of errors and inconsistency.
**Why:** Stately tooling provides visual aids, code generation, and validation, ensuring correctness and consistency. This reduces manual effort and potential errors in complex state machine logic.
### 1.2 IDE Configuration
**Standard:** Configure your IDE (VS Code, WebStorm, etc.) with appropriate plugins and settings for XState development.
**Do This:**
* Install the official Stately VS Code extension for enhanced syntax highlighting and machine visualization.
* Enable TypeScript strict mode (""strict": true" in "tsconfig.json") for robust type checking.
* Set up file watchers to automatically generate TypeScript types from your machine definitions using the XState CLI ("npx @statelyai/cli@latest typegen --watch src/machines/*.ts").
"""json
// tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["@statelyai/xstate/types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
"""
**Don't Do This:** Develop XState code without proper IDE support. This reduces productivity and increases the risk of typing errors.
**Why:** IDE plugins provide valuable assistance with syntax highlighting, validation, code completion, and visualization of state machines, leading to a smoother and more efficient development experience. TypeScript's strict mode catches potential errors early on.
## 2. Type Safety and Code Generation
### 2.1 Leveraging TypeScript
**Standard:** Embrace TypeScript for all XState code. Use the typegen feature of "@statelyai/cli" to automatically generate TypeScript definitions from your machine configuration.
**Do This:**
* Define and utilize TypeScript types for events, context, and state values.
* Use the "createMachine" function with explicit type parameters to ensure strong typing throughout your state machines.
"""typescript
import { createMachine, assign } from 'xstate';
import { createTypeHelper } from '@statelyai/xstate'; //For XState V5
interface Context {
count: number;
name: string;
}
type Events =
| { type: 'INC' }
| { type: 'DEC' }
| { type: 'SET_NAME'; name: string };
// Create a type helper
const typeHelper = createTypeHelper();
const counterMachine = createMachine({
/** @xstate-typegen "path/to/counterMachine.typegen.ts" */
id: 'counter',
initial: 'idle',
context: {
count: 0,
name: '',
},
states: {
idle: {
on: {
INC: {
actions: assign(typeHelper.updateContext({ count: (context) => context.count + 1 })),
},
DEC: {
actions: assign(typeHelper.updateContext({ count: (context) => context.count - 1 })),
},
SET_NAME: {
actions: assign(typeHelper.updateContext((context, event) => {
if (event.type === 'SET_NAME') {
return { name: event.name };
}
return {}; // Explicit return for non-matching events
})),
},
},
},
},
}, {guards:{/*...*/}, actions:{/*...*/}});
//Inferred types from createMachine call
export type CounterEvent = typeof counterMachine.events;
export type CounterState = typeof counterMachine.initialState;
"""
**Don't Do This:** Avoid using TypeScript or defining explicit types for your state machines. This reduces code reliability and makes it harder to refactor.
**Why:** TypeScript eliminates runtime type errors, improves code maintainability, and enhances the development experience by providing autocompletion and type checking. Typegen removes the need to manually define these types.
### 2.2 Typegen Configuration
**Standard:** Configure "@statelyai/cli" to generate TypeScript type definitions that provide context and event types.
**Do This:**
* Add the "/** @xstate-typegen "path/to/machine.typegen.ts" */" comment to your machine definition.
* Run "npx @statelyai/cli@latest typegen --wash src/machines/*.ts".
* Import and utilize the generated types for your machine's context, events, and states.
"""typescript
// counterMachine.typegen.ts (Example autogenerated)
export type Typegen0 = {
'meta': {
'machine': 'counter';
'missingImplementations': {
'actions': never;
'delays': never;
'guards': never;
'services': never;
};
'eventsCausingActions': {
'assignCount': 'DEC' | 'INC';
'assignName': 'SET_NAME';
};
'eventsCausingDelays': {};
'eventsCausingGuards': {};
'eventsCausingServices': {};
'matchesStates': 'idle';
'tags': never;
};
'matchesContext': {
'count': number;
'name': string;
};
};
"""
**Don't Do This:** Manually define TypeScript types for state machine context or events, leading to potential inconsistencies with the machine definition. Or, forget to add the comment tag triggering typegen.
**Why:** Automated type generation keeps your TypeScript types synchronized with your state machine definition, preventing type-related errors and enabling safer refactoring.
## 3. Testing Strategies
### 3.1 Unit Testing
**Standard:** Unit test individual transitions and side effects within your state machines.
**Do This:**
* Use the "test" method with the XState machine to assert state transitions and context updates.
* Mock external dependencies (e.g., API calls, timers) using techniques like "jest.mock" or "sinon.js".
"""typescript
import { interpret } from 'xstate';
import {fetchDataMachine} from './fetchDataMachine'
describe('fetchDataMachine', () => {
it('should transition to the "success" state when data is successfully fetched', (done) => {
const mockData = { message: 'Data fetched successfully' };
const service = interpret(fetchDataMachine.withConfig({
services: {
fetchData: () => Promise.resolve(mockData)
}
}))
.onTransition((state) => {
if (state.matches('success')) {
expect(state.context.data).toEqual(mockData);
done();
}
})
.start();
service.send('FETCH');
});
it('Handles failure to fetch data correctly', (done) => {
const service = interpret(fetchDataMachine.withConfig({
services: {
fetchData: () => Promise.reject("Failed to Fetch")
}
}))
.onTransition((state) => {
if(state.matches("failure")){
expect(state.context.error).toEqual("Failed to Fetch")
done();
}
})
.start()
service.send("FETCH");
})
});
"""
**Don't Do This:** Focus solely on integration tests that verify the entire application flow. Neglect unit tests of specific states and transitions.
**Why:** Unit tests isolate and verify the behavior of individual parts of your state machine, ensuring correctness and facilitating debugging.
### 3.2 Visual Testing with Stately Inspect
**Standard:** Leverage Stately Inspect for visualizing state transitions and debugging machine behavior in real-time.
**Do This:**
* Integrate "@statelyai/inspect" with your application to enable real-time state machine visualization in your browser's developer tools.
* Use Stately Inspect to identify unexpected state transitions, incorrect context updates, and race conditions.
"""typescript
import { inspect } from '@statelyai/inspect';
import { interpret } from 'xstate';
import { myMachine } from './myMachine';
inspect({
iframe: false, // default true
url: 'https://stately.ai/inspect', // default Stately Inspect location
});
const service = interpret(myMachine).start();
"""
**Don't Do This:** Rely solely on console logging or manual debugging to understand state machine behavior.
**Why:** Stately Inspect provides a visual representation of your state machine's execution, making it significantly easier to understand its behavior and identify potential issues.
## 4. Ecosystem Libraries and Integration
### 4.1 Actor Model Integration: "useActor"
**Standard:** Use "@xstate/react" hooks like "useActor" to connect XState machines to UI components in React applications (or appropriate integrations for your chosen framework like "useService" or equivalent).
**Do This:**
* Use "useActor" to start, manage, and subscribe to the state of an XState actor in your UI components.
* Pass the state and send function returned by "useActor" down as props to deeply nested components to maintain a clear separation of concerns.
"""typescript
import { useActor } from '@xstate/react';
import { counterMachine } from './counterMachine';
function CounterComponent() {
const [state, send] = useActor(counterMachine);
return (
Count: {state.context.count}
send({ type: 'INC' })}>Increment
send({ type: 'DEC' })}>Decrement
<p>Name: {state.context.name}</p>
{ send({ type: 'SET_NAME', name: e.currentTarget.value })}} />
);
}
"""
**Don't Do This:** Directly manipulate state machine instances within UI components, bypassing the "useActor" (or similar) hook.
**Why:** "useActor" provides a clean and reactive API for integrating XState machines into UI components. Handles lifecycle methods and re-renders.
### 4.2 Persistence and Hydration
**Standard:** Implement a strategy for persisting and hydrating state machine state, especially in complex applications.
**Do This:**
* Use "localStorage", "sessionStorage", or a dedicated state persistence library (e.g., "redux-persist") to save the machine's context and current state.
* Upon application initialization, hydrate the machine with the persisted state using "machine.withContext()" and "machine.withState()".
* Consider Stately Registry for more robust collaboration and testing.
"""typescript
import { createMachine } from 'xstate';
const machine = createMachine({
context: { value: 0 },
/* ... */
})
const persistedState = localStorage.getItem('myMachineState');
if (persistedState) {
machine.withState(JSON.parse(persistedState));
}
// Persist state on transition
machine.onTransition((state) => {
localStorage.setItem('myMachineState', JSON.stringify(state));
});
"""
**Don't Do This:** Assume that state is automatically preserved across page reloads or browser sessions, leading to data loss.
**Why:** Persistence ensures that your application can restore its state after unexpected interruptions, providing a better user experience. Stately registry allows persistence in the cloud and can be shared within teams.
### 4.3 Advanced Tooling Ecosystem
For more complex implementations:
* **Stately Editor** for visualizing, designing, and simulating machines.
* **Stately Sky** for hosting and maintaining shared workflows and machines.
* **XState DevTools** for inspecting running machines in real-time.
* **@xstate/test** integrating tests into machine definitions
## 5. Anti-Patterns and Common Mistakes
### 5.1 Over-Reliance on Stringly-Typed Events
**Anti-Pattern:** Using string literals for events without defined types;
"""javascript
// ANIT-PATTERN
send("EVENT_NAME")
"""
**Solution:** Use Typescript typing (covered above) to ensure type safety for events.
### 5.2 Directly Mutating Context
**Anti-Pattern:** Modifying the state machine's context directly instead of using the "assign" action or "updateContext" helper.
"""javascript
// ANTI-PATTERN
const machine = createMachine({
/* ... */
actions: {
updateContext: (context, event) => {
context.count = event.value; // Direct mutation!
},
},
});
"""
**Solution:** Always use the "assign" action or "updateContext" helper to update the context immutably. As shown above in example code.
### 5.3 Excessive Complexity
**Anti-Pattern:** Creating overly complex state machines with too many states, transitions, and actions.
**Solution:** Break down complex machines into smaller, more manageable submachines using the "invoke" property or composite states to improve readability and maintainability. See documentation on composition. Also consider using the Stately Editor to visualize the machine.
"""typescript
// Example of composition
import { createMachine, invoke } from 'xstate';
const submachine = createMachine({
id: 'submachine',
initial: 'idle',
states: {
idle: { on: { DO_SOMETHING: 'busy' } },
busy: { type: 'final' },
},
});
const parentMachine = createMachine({
id: 'parent',
initial: 'loading',
states: {
loading: {
invoke: {
id: 'submachine-instance',
src: submachine,
onDone: 'success',
onError: 'failure',
},
},
success: { type: 'final' },
failure: { type: 'final' },
},
});
"""
### 5.4 Ignoring type-safety with "any"
**Anti-Pattern:** Resorting to "any" types to avoid type errors, defeating the purpose of TypeScript.
**Solution:** Take the time to properly define the types for your context, events, and states. Use the Stately typegen feature to automatically generate these types. If necessary, use discriminated unions to handle different event types.
## 6. Optimization and Performance
### 6.1 Minimize Side Effects in Guards
**Standard:** Keep guards pure and free of side effects to avoid unexpected state changes or performance bottlenecks.
**Do This:**
* Ensure that guards only evaluate the context and event data without modifying it or triggering external operations.
"""typescript
// Best Practice
const machine = createMachine({
/* ... */
states: {
active: {
on: {
CHECK: {
target: 'inactive',
guard: (context, event) => context.count > 10, // Pure guard
},
},
},
},
});
"""
**Don't Do This:** Perform asynchronous operations or complex calculations within guards, as this can lead to significant performance issues.
**Why:** Guards are evaluated frequently during state transitions, so keeping them lightweight is crucial for maintaining performance.
### 6.2 Debouncing and Throttling Event Handling
**Standard:** Implement debouncing or throttling for events that are triggered frequently to prevent excessive state transitions and improve responsiveness.
**Do This:**
* Use the "delay" property to debounce or throttle event handling.
"""typescript
import { createMachine, assign } from 'xstate';
const searchMachine = createMachine({
id: 'search',
initial: 'idle',
context: {
query: '',
results: [],
},
states: {
idle: {
on: {
INPUT: {
target: 'searching',
actions: assign({ query: (context, event) => event.query }),
delay: 300, // Debounce input by 300ms
},
},
},
searching: {
// ...
},
},
});
"""
**Don't Do This:** React to every event immediately without considering the frequency and potential performance impact.
### 6.3 Lazy Loading
**Standard:** For very large state machines, especially those that can be serialized and rehydrated, consider lazy loading parts of the machine definition. This is an advanced optimization that should be approached carefully.
**Do This:**
* Break the machine into smaller submachines
* Dynamically import these submachines or machine configurations when needed
"""typescript
//Lazy Loaded SubMachine Example
async function loadSubMachine(){
const {subMachine} = await import('./mySubMachine'); // Dynamic import
return subMachine
}
const parentMachine = createMachine({
id: 'MainMachine',
//...
states: {
//...
heavyState: {
entry: async () => {
const subMachine = await loadSubMachine;
//...Do something with submachine...
}}
}
})
"""
**Don't Do This:** Over-engineer lazy loading in smaller or medium sized state machines. Only use it for very large machines or those with complex, optional features.
## 7. Security Considerations
### 7.1 Input Validation
**Standard:** Validate all external input (e.g. UI input, API response) before using it to update the state machine's context or trigger transitions.
**Do This:**
* Use validation libraries like io-ts, zod, or JSON schema to validate event data and context values.
* Implement guards to check the validity of input data before transitioning to new states or performing actions.
"""typescript
import { createMachine, assign } from 'xstate';
import * as t from 'io-ts';
import { PathReporter } from 'io-ts/PathReporter';
// Define a schema for the event data
const UserSchema = t.type({
name: t.string,
age: t.number,
});
const machine = createMachine({
/* ... */
states: {
idle: {
on: {
SET_USER: {
actions: assign((context, event) => {
// Validate the event data
const validation = UserSchema.decode(event.user);
if (validation._tag === 'Left') {
// Handle validation errors
console.error(PathReporter.report(validation).join('\n'));
return {}; // Or throw an error
}
return { user: validation.right };
}),
},
},
},
},
});
"""
**Don't Do This:** Trust that all input data is valid and safe. Always validate untrusted data.
**Why:** Input validation prevents malicious or malformed data from corrupting the state machine's context or causing unexpected behavior.
### 7.2 Sensitive Data Handling
**Standard:** Protect sensitive data (e.g., API keys, user credentials) stored in the state machine's context.
**Do This:**
* Encrypt sensitive data before storing it in the context.
* Avoid logging sensitive data to the console or storing it in local storage without proper protection.
* Use environment variables to store sensitive configuration values.
**Don't Do This:** Hardcode sensitive data directly into the state machine definition or expose it to the client-side code.
### 7.3 Rate Limiting
**Standard:** Implement rate limiting to prevent abuse of state machine functionality, especially for actions that trigger external operations or modify data. Can be implemented in guards by tracking time-stamps within the context object.
**Do This:**
* Use a rate-limiting library or middleware to restrict the number of requests from a single user or IP address within a given time period.
**Don't Do This:** Allow unlimited access to state machine functionality without implementing appropriate security measures.
This document provides a detailed guide to XState coding standards, particularly focusing on tooling and ecosystem considerations with an eye towards latest versions and best practices. Adherence to these guidelines will promote code quality, maintainability, and security in your projects.
danielsogl
Created Mar 6, 2025
This guide explains how to effectively use .clinerules
with Cline, the AI-powered coding assistant.
The .clinerules
file is a powerful configuration file that helps Cline understand your project's requirements, coding standards, and constraints. When placed in your project's root directory, it automatically guides Cline's behavior and ensures consistency across your codebase.
Place the .clinerules
file in your project's root directory. Cline automatically detects and follows these rules for all files within the project.
# Project Overview project: name: 'Your Project Name' description: 'Brief project description' stack: - technology: 'Framework/Language' version: 'X.Y.Z' - technology: 'Database' version: 'X.Y.Z'
# Code Standards standards: style: - 'Use consistent indentation (2 spaces)' - 'Follow language-specific naming conventions' documentation: - 'Include JSDoc comments for all functions' - 'Maintain up-to-date README files' testing: - 'Write unit tests for all new features' - 'Maintain minimum 80% code coverage'
# Security Guidelines security: authentication: - 'Implement proper token validation' - 'Use environment variables for secrets' dataProtection: - 'Sanitize all user inputs' - 'Implement proper error handling'
Be Specific
Maintain Organization
Regular Updates
# Common Patterns Example patterns: components: - pattern: 'Use functional components by default' - pattern: 'Implement error boundaries for component trees' stateManagement: - pattern: 'Use React Query for server state' - pattern: 'Implement proper loading states'
Commit the Rules
.clinerules
in version controlTeam Collaboration
Rules Not Being Applied
Conflicting Rules
Performance Considerations
# Basic .clinerules Example project: name: 'Web Application' type: 'Next.js Frontend' standards: - 'Use TypeScript for all new code' - 'Follow React best practices' - 'Implement proper error handling' testing: unit: - 'Jest for unit tests' - 'React Testing Library for components' e2e: - 'Cypress for end-to-end testing' documentation: required: - 'README.md in each major directory' - 'JSDoc comments for public APIs' - 'Changelog updates for all changes'
# Advanced .clinerules Example project: name: 'Enterprise Application' compliance: - 'GDPR requirements' - 'WCAG 2.1 AA accessibility' architecture: patterns: - 'Clean Architecture principles' - 'Domain-Driven Design concepts' security: requirements: - 'OAuth 2.0 authentication' - 'Rate limiting on all APIs' - 'Input validation with Zod'
# Core Architecture Standards for XState This document outlines the core architectural standards for developing XState applications. It focuses on fundamental patterns, project structure, and organization principles specifically applied to XState projects. These standards aim to improve maintainability, performance, and security while leveraging the latest XState features. ## 1. Overall Architecture and Project Structure ### 1.1. Standard: Component-Based Architecture **Do This:** Organize your application using a component-based architecture where each component encapsulates its own state machine. **Don't Do This:** Create monolithic state machines that manage the entire application state. Avoid tightly coupling components. **Why:** Component-based architectures promote modularity, reusability, and testability. Large, monolithic state machines become difficult to understand and maintain as the application grows. **Example:** """typescript // counterMachine.ts import { createMachine } from 'xstate'; export const counterMachine = createMachine({ id: 'counter', initial: 'idle', context: { count: 0 }, states: { idle: { on: { INC: { actions: ['increment'] }, DEC: { actions: ['decrement'] } } } }, actions: { increment: (context) => { context.count += 1; }, decrement: (context) => { context.count -= 1; } } }); // CounterComponent.tsx (React example) import React from 'react'; import { useMachine } from '@xstate/react'; import { counterMachine } from './counterMachine'; const CounterComponent = () => { const [state, send] = useMachine(counterMachine); return ( <div> <p>Count: {state.context.count}</p> <button onClick={() => send('INC')}>Increment</button> <button onClick={() => send('DEC')}>Decrement</button> </div> ); }; export default CounterComponent; """ ### 1.2. Standard: Feature-Based Folder Structure **Do This:** Organize your project directory by features or modules rather than by file type. **Don't Do This:** Having folders like "/components", "/services", "/utils" at the top level. **Why:** Feature-based organization improves discoverability and reduces the cognitive load on developers. It makes it easier to locate all the related files for a specific feature. **Example:** """ /src /counterFeature // Feature directory counterMachine.ts // XState Machine CounterComponent.tsx // React Component counter.styles.ts // Styles specific to the feature counter.test.ts // Tests /authFeature authMachine.ts AuthComponent.tsx authService.ts /app // Application wide files App.tsx index.tsx """ ### 1.3. Standard: Machine Definition Location **Do This:** Place machine definitions in dedicated files that describe their specific function within the feature. **Don't Do This:** Define machines directly within component files. The machine definition should be its own module. **Why:** Reduces complexity, makes the machine definition reusable, and promotes separation of concerns. **Example:** Refers to code example to the counterMachine definition in previous section. ### 1.4 Standard: State Chart Visualization **Do This:** Use Stately Studio or similar tools to visually design and document your state machines. **Don't Do This:** Rely solely on code to understand the structure and behavior of your machines. **Why:** Visualizations improve understanding, facilitate collaboration, and help identify potential issues early in the development process. Stately Studio also provides code generation capabilities which can improve developer efficiency and accuracy. ## 2. State Machine Design Principles ### 2.1. Standard: Explicit State Definitions **Do This:** Define all possible states of your machine explicitly, including "idle", "loading", "success", and "error" states. **Don't Do This:** Rely on implicit state management or boolean flags to represent state. **Why:** Explicit state definitions increase clarity, prevent unexpected behavior, and make it easier to reason about the state of your application. **Example:** """typescript import { createMachine } from 'xstate'; export const dataFetchingMachine = createMachine({ id: 'dataFetching', initial: 'idle', context: { data: null, error: null }, states: { idle: { on: { FETCH: 'loading' } }, loading: { entry : ['fetchData'], on: { RESOLVE: { target: 'success', actions: ['setData'] }, REJECT: { target: 'failure', actions: ['setError'] } } }, success: { type: 'final', entry: ['logData'] }, failure: { on: { RETRY: 'loading' }, entry: ['logError'] } }, actions: { setData: (context, event) => { context.data = event.data; }, setError: (context, event) => { context.error = event.error; }, logData: (context) => { console.log("Success: ", context.data); }, logError: (context) => { console.error("Error: ", context.error); }, fetchData: async (context, event) => { try { const data = await fetchData(); //Async function to fetch data return send({type: 'RESOLVE', data}) } catch (error) { return send({type: 'REJECT', error}) } } } }); async function fetchData() { // Simulate an API call return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.2; // Simulate occasional failure if (success) { resolve({ message: "Data loaded successfully!" }); } else { reject(new Error("Failed to fetch data.")); } }, 1000); }); } """ ### 2.2. Standard: Guard Conditions for Transitions **Do This:** Use guard conditions ("guards") to control transitions based on context or event data. **Don't Do This:** Use imperative logic within actions to determine the next state. **Why:** Guards make the transition logic declarative and easier to understand. They separate decision-making logic from state transitions. **Example:** """typescript import { createMachine } from 'xstate'; interface Context { age: number; } type Event = { type: 'CHECK_AGE'; age: number }; export const ageVerificationMachine = createMachine<Context, Event>({ id: 'ageVerification', initial: 'unknown', context: { age: 0 }, states: { unknown: { on: { CHECK_AGE: [ { target: 'adult', guard: 'isAdult' }, { target: 'minor' } ] } }, adult: { type: 'final' }, minor: { type: 'final' } }, guards: { isAdult: (context, event) => { return event.age >= 18; } } }); """ ### 2.3. Standard: Context Management **Do This:** Define and manage the machine's context explicitly. Use actions to update the context based on events. **Don't Do This:** Modify external variables directly from within the machine. **Why:** Explicit context management ensures that all relevant data is encapsulated within the machine, making it easier to reason about state changes and data flow. **Example:** (Refer to counterMachine.ts example for an explicit context example.) ### 2.4 Standard: Statelessness of Machines **Do This:** Ensure that the machine definition itself is stateless. Store all dynamic data within the "context". **Don't Do This:** Bake-in dynamic data inside the machine definition; this defies its statelessness. **Why:** This allows multiple instances of the same machine to exist without sharing the state, maintaining integrity and predictability. ## 3. Action Implementation ### 3.1. Standard: Named Actions **Do This:** Use named actions that clearly describe the purpose of the action. **Don't Do This:** Use anonymous, inline actions, especially for complex logic. **Why:** Named actions improve readability and allow for reuse of action logic. **Example:** """typescript import { createMachine } from 'xstate'; export const lightMachine = createMachine({ id: 'light', initial: 'green', states: { green: { on: { TIMER: 'yellow' }, entry: ['logGreen'] }, yellow: { on: { TIMER: 'red' }, entry: ['logYellow'] }, red: { on: { TIMER: 'green' }, entry: ['logRed'] } }, actions: { logGreen: () => { console.log('Entering green state'); }, logYellow: () => { console.log('Entering yellow state'); }, logRed: () => { console.log('Entering red state'); } } }); """ ### 3.2. Standard: Side Effect Management **Do This:** Isolate side effects (e.g., API calls, DOM manipulations) within actions. Use "invoke" for asynchronous operations. **Don't Do This:** Perform side effects directly within components or services outside the machine. **Why:** Isolating side effects improves testability and makes it easier to manage asynchronous operations and potential errors. **Example** (Using "invoke" for data fetching - builds on the previous "dataFetchingMachine" example from 2.1) """typescript import { createMachine, send } from 'xstate'; export const dataFetchingMachine = createMachine({ id: 'dataFetching', initial: 'idle', context: { data: null, error: null }, states: { idle: { on: { FETCH: 'loading' } }, loading: { invoke: { id: 'fetchData', src: 'fetchDataService', onDone: { target: 'success', actions: ['setData'] }, onError: { target: 'failure', actions: ['setError'] } } }, success: { type: 'final', entry: ['logData'] }, failure: { on: { RETRY: 'loading' }, entry: ['logError'] } }, actions: { setData: (context, event) => { context.data = event.data; }, setError: (context, event) => { context.error = event.data; //Event.data contains the error object automatically for invokes }, logData: (context) => { console.log("Success: ", context.data); }, logError: (context) => { console.error("Error: ", context.error); } } }, { services: { fetchDataService: async (context, event) => { const data = await fetchData(); //Async function to fetch data return data; } } }); async function fetchData() { // Simulate an API call return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.2; // Simulate occasional failure if (success) { resolve({ message: "Data loaded successfully!" }); } else { reject(new Error("Failed to fetch data.")); } }, 1000); }); } """ ### 3.3 Standard: Using "assign" Action **Do This:** Use the "assign" action provided by XState to update the machine's context immutably for clarity and predictability. Use the "raise" action for forwarding events to the same machine. **Don't Do This:** Mutate the context directly, or attempt to send events using side effects. **Why**: "assign" ensures predictable state transitions, which makes debugging and testing much easier. The "raise" action ensures events are properly processed within the machine. **Example:** """typescript import { createMachine, assign, raise } from 'xstate'; interface MyContext { value: number; } type MyEvent = { type: 'INC' } | { type: 'DEC' } | { type: 'NOTIFY' }; const myMachine = createMachine<MyContext, MyEvent>({ id: 'myMachine', initial: 'active', context: { value: 0 }, states: { active: { on: { INC: { actions: assign({ value: (context) => context.value + 1 }) }, DEC: { actions: assign({ value: (context) => context.value - 1 }) }, NOTIFY: { actions: raise({ type: 'INC' }) // Internally increment the value } } } } }); """ ### 3.4. Standard: Event Naming Conventions **Do This:** Use clear and consistent naming conventions for events. Use verbs or nouns that describe the event’s intent (e.g., "FETCH", "SUBMIT", "DATA_LOADED"). **Don't Do This:** Use generic or ambiguous event names (e.g., "EVENT", "ACTION"). **Why:** Clear event names improve readability and reduce the risk of confusion in complex state machines. ## 4. Integration with Frameworks ### 4.1. Standard: Hooks for State Management **Do This:** Use XState's provided hooks (e.g., "useMachine" in React) to connect machines to your UI components. **Don't Do This:** Manually subscribe to the machine's state updates or manage event sending imperatively outside component. **Why:** The XState hooks simplify integration with UI frameworks and handle lifecycle management automatically. **Example:** (React example demonstrating the use of useMachine, builds on counterMachine.ts example from 1.1.) """typescript import React from 'react'; import { useMachine } from '@xstate/react'; import { counterMachine } from './counterMachine'; const CounterComponent = () => { const [state, send] = useMachine(counterMachine); return ( <div> <p>Count: {state.context.count}</p> <button onClick={() => send('INC')}>Increment</button> <button onClick={() => send('DEC')}>Decrement</button> </div> ); }; export default CounterComponent; """ ### 4.2. Standard: Derived State **Do This**: Consider creating derived state within your components using selectors based on the machine's context. **Don't Do This**: Duplicate state or create complex logic within your components that should be handled by the state machine **Why**: This keeps the state machine as the single source of truth and prevents inconsistencies. **Example:** """typescript //Counter Component import React from 'react'; import { useMachine } from '@xstate/react'; import { counterMachine } from './counterMachine'; const CounterComponent = () => { const [state, send] = useMachine(counterMachine); const isEven = state.context.count % 2 === 0; //Derived value return ( <div> <p>Count: {state.context.count} (Even: {isEven ? 'Yes' : 'No'})</p> <button onClick={() => send('INC')}>Increment</button> <button onClick={() => send('DEC')}>Decrement</button> </div> ); }; export default CounterComponent; """ ## 5. Testing and Debugging ### 5.1. Standard: Unit Tests for State Machines **Do This:** Write unit tests for your state machines to ensure that they transition correctly and handle events as expected. Use "@xstate/test" for thorough testing. **Don't Do This:** Rely solely on manual testing or integration tests. **Why:** Unit tests provide fast feedback and help prevent regressions as the application evolves. **Example** (Using @xstate/test): """typescript import { createMachine } from 'xstate'; import { createModel } from '@xstate/test'; const bookingMachine = createMachine({ id: 'booking', initial: 'idle', context: { seats: 0 }, states: { idle: { on: { INITIATE: 'pending' } }, pending: { on: { RESOLVE: {target: 'confirmed', actions: assign({seats : (context, event) => event.seats})}, REJECT: 'rejected' } }, confirmed: { type: 'final' }, rejected: { on: { RETRY: 'pending' } } } }); const bookingModel = createModel(bookingMachine).withEvents({ INITIATE: {}, RESOLVE: { exec: async () => { return new Promise((resolve) => { setTimeout(() => { resolve(true); }, 500); }); }, cases: [{ seats: 5 }, { seats: 10 }] }, REJECT: {}, RETRY: {} }); describe('booking machine', () => { const testPlans = bookingModel.getShortestPathPlans(); testPlans.forEach((plan) => { describe(plan.description, () => { plan.paths.forEach((path) => { it(path.description, async () => { await path.test(); }); }); }); }); it('should have full coverage', () => { return bookingModel.testCoverage(); }); }); """ ### 5.2. Standard: Debugging Tools **Do This:** Use the XState Visualizer in Stately Studio and browser developer tools to inspect the machine's state and events during runtime. **Don't Do This:** Rely solely on "console.log" statements for debugging. **Why:** Visual debugging tools provide a more intuitive and efficient way to understand the behavior of your state machines. XState inspector tools allow for connecting your running XState machines to the Stately Studio visualizer seamlessly. ### 5.3. Standard: Mock Services in Tests **Do This:** Mock external services (e.g., API calls) in your tests to isolate the state machine and ensure deterministic behavior. **Don't Do This:** Make real API calls during unit tests. **Why:** Mocking services makes tests faster, more reliable, and independent of external dependencies. ## 6. Advanced Architectural Patterns ### 6.1. Standard: Hierarchical and Parallel States **Do This:** Use hierarchical (nested) and parallel states to model complex state transitions and concurrent activities. **Don't Do This:** Flatten complex state logic into a single level of states due to added complexity. **Why:** Hierarchical and parallel states improve the organization and readability of complex state machines. **Example (Hierarchical/Nested States):** """typescript import { createMachine } from 'xstate'; export const audioPlayerMachine = createMachine({ id: 'audioPlayer', initial: 'idle', states: { idle: { on: { PLAY: 'playing' } }, playing: { states: { buffering: { on: { LOADED: 'ready' } }, ready: { on: { SEEK: 'buffering' } } }, initial: 'buffering', on: { PAUSE: 'paused', STOP: 'idle' } }, paused: { on: { PLAY: 'playing', STOP: 'idle' } } } }); """ ### 6.2. Standard: History States **Do This:** Employ history states when you need to return to a previously active substate within a hierarchical state. **Don't Do This:** Manually store the last active state. **Why:** History states simplify returning to a previous state after an interruption and reduce the complexity of manual state tracking. """typescript import { createMachine } from 'xstate'; const documentMachine = createMachine({ id: 'document', initial: 'editing', states: { editing: { states: { text: { on: { FORMAT: 'formatting' } }, formatting: { type: 'history' } }, initial: 'text', on: { SAVE: 'saving' } }, saving: { type: 'final' } } }); """ ### 6.3. Standard: Compound Actions **Do This:** Utilize compound actions (actions that call other actions or send events) to compose complex behaviors. **Don't Do This:** Create excessively long action definitions. **Why:** It improves reusability and readability. XState's "choose" action can act as a "conditional action". **Example:** """typescript import { createMachine, assign, choose } from 'xstate'; interface MyContext { attempts: number; success: boolean; } type MyEvent = { type: 'SUBMIT' } | { type: 'RETRY' } | { type: 'SUCCESS' } | { type: 'FAILURE' }; const submissionMachine = createMachine<MyContext, MyEvent>({ id: 'submission', initial: 'idle', context: { attempts: 0, success: false }, states: { idle: { on: { SUBMIT: 'submitting' } }, submitting: { entry: 'incrementAttempts', invoke: { id: 'submitData', src: 'submitDataService', onDone: { target: 'success', actions: 'markSuccess' }, onError: { target: 'failure' } } }, success: { type: 'final' }, failure: { on: { RETRY: { target: 'submitting', cond: 'canRetry' } }, exit: choose([ { cond: (context) => context.attempts > 3, actions: () => console.log("Max attempts reached") } ]) } }, actions: { incrementAttempts: assign({ attempts: (context) => context.attempts + 1 }), markSuccess: assign({ success: true }) }, guards: { canRetry: (context) => context.attempts < 3 }, services: { submitDataService: async () => { // Simulate an API call return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.5; if (success) { resolve("Data submitted successfully!"); } else { reject(new Error("Submission failed.")); } }, 1000); }); } } }); """ This coding standard provides a strong foundation for building robust and maintainable XState applications. By adhering to these guidelines, development teams can ensure consistency, improve code quality, and leverage the full power of the XState library.
# Component Design Standards for XState This document outlines the best practices for designing reusable and maintainable components within the XState ecosystem. It focuses on component design principles applied specifically to XState state machines and actors. Adhering to these standards will lead to more robust, understandable, and scalable applications. ## 1. Principles of Reusable XState Components ### 1.1. Separation of Concerns **Standard:** Separate the state machine logic from the presentation layer. Components should be responsible solely for rendering the current state and dispatching events, not for defining state transitions or side effects. **Why:** Decoupling the concerns improves testability, maintainability, and reusability. Changes in the UI don't require modifications to the state machine (and vice versa). **Do This:** """javascript // CounterMachine.js (State Machine Definition) import { createMachine } from 'xstate'; export const counterMachine = createMachine({ id: 'counter', initial: 'idle', context: { count: 0 }, states: { idle: { on: { INC: { actions: 'increment' }, DEC: { actions: 'decrement' } } } }, actions: { increment: (context) => { context.count += 1; }, decrement: (context) => { context.count -= 1; } } }); """ """jsx // CounterComponent.jsx (React Component) import React from 'react'; import { useMachine } from '@xstate/react'; import { counterMachine } from './CounterMachine'; function CounterComponent() { const [state, send] = useMachine(counterMachine); return ( <div> <p>Count: {state.context.count}</p> <button onClick={() => send('INC')}>Increment</button> <button onClick={() => send('DEC')}>Decrement</button> </div> ); } export default CounterComponent; """ **Don't Do This:** """javascript // BAD: Mixing UI logic with state machine definition import React, { useState } from 'react'; import { createMachine, useMachine} from 'xstate'; function BadCounterComponent() { const [count, setCount] = useState(0); //UI State const counterMachine = createMachine({ id: 'counter', initial: 'idle', context: { count: 0, }, states: { idle: { on: { INC: { actions: () => setCount(count + 1) }, DEC: { actions: () => setCount(count - 1) }, }, }, }, }); const [state, send] = useMachine(counterMachine); return ( <div> <p>Count: {count}</p> {/* Directly using the state value */} <button onClick={() => send('INC')}>Increment</button> <button onClick={() => send('DEC')}>Decrement</button> </div> ); } export default BadCounterComponent; """ **Anti-pattern:** Mixing UI state (e.g., using "useState" directly within the React component) with the state managed by XState. The XState machine should be the single source of truth for the component's state. ### 1.2. Single Responsibility Principle (SRP) for Machines **Standard:** Each state machine should have a clearly defined, single purpose. Avoid creating monolithic state machines that handle multiple unrelated functionalities. **Why:** Smaller, focused machines are easier to understand, test, and reuse. Changes to one feature are less likely to impact others. **Do This:** Create separate machines for authentication, data fetching, and UI state management, even if they interact with each other. Use parent-child relationships or composition patterns to orchestrate their behavior. **Don't Do This:** Combine unrelated logic into a single, complex state machine. This leads to complexity, difficulty in testing, and reduced reusability. ### 1.3. Composability **Standard:** Design machines to be composable. Use techniques like "invoke" and "assign" within one machine to call and update state or data from another, promoting modularity. **Why:** Composability lets you reuse existing machines in different contexts, reducing code duplication and improving maintainability. **Do This:** """javascript // ChildMachine.js import { createMachine } from 'xstate'; export const childMachine = createMachine({ id: 'child', initial: 'idle', states: { idle: { type: 'atomic', on: { 'ACTIVATE': 'active' } }, active: { type: 'atomic', entry: (context) => { console.log("Child ACTIVATED!", context); }, on: { 'DEACTIVATE': 'idle' } } } }); // ParentMachine.js import { createMachine } from 'xstate'; import { childMachine } from './ChildMachine'; export const parentMachine = createMachine({ id: 'parent', initial: 'loading', states: { loading: { entry: (context) => { console.log("Loading...", context); }, after: { 2000: 'ready' } }, ready: { invoke: { id: 'myChild', src: childMachine, //Reference Child Machine onDone: { target: 'finished', actions: (context, event) => { console.log('Child finished!', event.data); // Optionally, update the parent context with data from the child //context.childResult = event.data; } } } }, finished: { type: 'final', entry: (context) => { console.log("Parent Finished!", context); } } } }); """ **Don't Do This:** Hardcode dependencies between machines. Machines should be able to function independently and be integrated without requiring significant modifications. ### 1.4. Abstraction **Standard:** Abstract away complex logic into reusable actions, services, and guards. Create custom hooks or utility functions that encapsulate common XState patterns. **Why:** Abstraction reduces code duplication and improves readability. It also makes it easier to update or change the underlying implementation without affecting the rest of the application. **Do This:** Create custom actions within your machine definitions. If you have repetitive asynchronous tasks, create a custom hook that handles machine invocation for cleaner code using services and promises. **Don't Do This:** Repeat the same complex logic in multiple places. This makes the code harder to maintain and increases the risk of introducing errors. ### 1.5. Immutability **Standard:** Treat the machine context as immutable. Avoid directly modifying the context object. Use "assign" actions to create new context objects with the desired changes. **Why:** Immutability simplifies debugging and improves performance by preventing unexpected side effects. It also makes it easier to reason about the state of the application. **Do This:** """javascript import { createMachine, assign } from 'xstate'; const immutableMachine = createMachine({ id: 'immutable', initial: 'idle', context: { data: { name: 'Initial Name', value: 0 } }, states: { idle: { on: { UPDATE_NAME: { actions: assign((context, event) => ({ data: { ...context.data, name: event.name } // Use the spread operator for immutability })) }, INCREMENT_VALUE: { actions: assign((context) => ({ data: { ...context.data, value: context.data.value + 1 } // Use the spread operator })) } } } } }); // Example: Send events to the machine // send({ type: 'UPDATE_NAME', name: 'New Name' }); // send({ type: 'INCREMENT_VALUE' }); """ **Don't Do This:** """javascript // BAD: Mutating the context directly import { createMachine, assign } from 'xstate'; const mutableMachine = createMachine({ id: 'mutable', initial: 'idle', context: { data: { name: 'Initial Name', value: 0 } }, states: { idle: { on: { UPDATE_NAME: { actions: (context, event) => { context.data.name = event.name; // Direct mutation } }, INCREMENT_VALUE: { actions: (context) => { context.data.value++; // Direct mutation } } } } } }); """ **Anti-pattern:** Directly modifying the "context" object leads to unpredictable behavior and difficulties in debugging. Always use "assign" to create new context objects. ## 2. Structure and Organization ### 2.1. Directory Structure **Standard:** Organize XState-related files in a dedicated directory (e.g., "/machines"). Group related machines and their associated components together. **Why:** A clear directory structure improves discoverability and maintainability. **Example:** """ src/ ├── components/ │ ├── Counter/ │ │ ├── CounterComponent.jsx │ │ └── index.js ├── machines/ │ ├── counterMachine.js │ └── index.js // Export all machines from here """ ### 2.2. Modularization with "createMachine" **Standard:** Define state machines using the "createMachine" function. This ensures proper type safety and enables you to define machines as separate modules that can be imported. **Why:** Ensures best practices are used. **Do This:** """javascript // myMachine.js import { createMachine } from 'xstate'; const myMachine = createMachine({ id: 'myMachine', initial: 'idle', states: { idle: { on: { ACTIVATE: 'active' } }, active: { on: { DEACTIVATE: 'idle' } } } }); export default myMachine; """ ### 2.3. Leveraging "MachineOptions" **Standard:** Utilize "MachineOptions" to configure actions, services, guards and delays. This keeps the machine definition clean and provides a single source of truth for the state machine's behavior. **Why:** Centralizes configuration improves readability and testability. **Do This:** """javascript // options.js export const machineOptions = { actions: { logEvent: (context, event) => console.log('Event:', event.type, 'Context:', context), notifyUser: (context, event) => alert("Event: ${event.type}") }, services: { fetchData: () => { return new Promise((resolve) => { setTimeout(() => { resolve({data: 'Fetched Data!!'}); }, 1000); }) } }, guards: { isDataAvailable: (context) => context.data !== null } }; // myMachine.js import { createMachine } from 'xstate'; import { machineOptions } from './options'; const myMachine = createMachine({ id: 'myMachine', initial: 'loading', context: { data: null }, states: { loading: { invoke: { id: 'fetchDataService', src: 'fetchData', // Referenced in the options onDone: { target: 'idle', actions: assign({ data: (context, event) => event.data //Context Assign }) }, onError: 'failed' } }, idle: { type: 'atomic', entry: 'logEvent', // Referenced in the options, on: { ACTIVATE: { target: 'active', cond: 'isDataAvailable' // Guard Referenced in the options, } } }, active: { type: 'atomic', entry: 'notifyUser', //Referenced in the options on: { DEACTIVATE: 'idle' } }, failed: { type: 'final' } } }, machineOptions); export default myMachine; """ **Don't Do This:** Define actions, services, and guards directly within the state machine definition, making it verbose and harder to read. ### 2.4. Naming Conventions **Standard:** Use descriptive and consistent naming conventions for states, events, actions, and services. * States: Use nouns or adjectives (e.g., "loading", "idle", "active", "error"). * Events: Use verbs or imperative phrases (e.g., "FETCH", "SUBMIT", "CANCEL"). * Actions: Use verbs or descriptions of the side effect (e.g., "increment", "logEvent", "notifyUser"). * Services: Use descriptions of the asynchronouse task (e.g., "fetchData", "submitForm"). **Why:** Clear naming improves readability. ## 3. Advanced Component Interactions ### 3.1. Using Actors and "invoke" for Complex Components **Standard:** Employ the "invoke" property to manage child actors (state machines or actors) within a parent machine. This allows for complex component hierarchies and communication between different parts of the application. **Why:** Provides clear state managment and communication between components. **Do This:** """javascript // Parent Machine import { createMachine } from 'xstate'; import { childMachine } from './ChildMachine'; const parentMachine = createMachine({ id: 'parent', initial: 'idle', context: { result: null }, states: { idle: { on: { START_CHILD: 'runningChild' } }, runningChild: { invoke: { id: 'childActor', src: childMachine, data: (context) => { return { parentContext: context }; // Pass parent context to child }, onDone: { target: 'done', actions: assign({ result: (context, event) => event.data // Capture result from child }) }, onError: 'failed' } }, done: { type: 'final', entry: (context) => {console.log("PARENT DONE!", context)} }, failed: { type: 'final' } } }); // Child Machine import { createMachine } from 'xstate'; const childMachine = createMachine({ id: 'child', initial: 'active', context: { parentContext: null, counter: 0, }, states: { active: { entry: (context) => {console.log("CHILD ACTIVE!!", context)}, after: { 2000: { target: 'success', actions: assign(context => { return{ counter: context.counter + 1 } }) } } }, success: { type: 'final', data: (context) => { console.log("CHILD SUCCESS", context); return {value: context.counter}; // Return data to parent } } } }); """ **Don't Do This:** Managing child component state directly within the parent component. This leads to tight coupling and makes it difficult to reason about the state of the application. ### 3.2. Custom Hooks for Reusable Logic **Standard:** Create custom React hooks that encapsulate common XState patterns and UI interactions. **Why:** Increases resuability. **Do This:** For instance, custom hooks can handle common state transitions for form submissions, data fetching, or authentication. """jsx // useDataFetching.js import { useMachine } from '@xstate/react'; import { createMachine, assign } from 'xstate'; import { useEffect } from 'react'; const dataFetchingMachine = createMachine({ id: 'dataFetching', initial: 'idle', context: { data: null, error: null }, states: { idle: { on: { FETCH: 'loading' } }, loading: { entry: (context) => {console.log("LOADING", context)}, invoke: { id: 'fetchData', src: (context) => { return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.5; if (success) { resolve({ data: 'Fetched Data!' }); } else { reject(new Error('Failed to fetch data.')); } }, 2000); }); }, onDone: { target: 'success', actions: assign({ data: (context, event) => event.data }) }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }) } } }, success: { entry: (context) => {console.log("SUCCESS!", context)}, type: 'final' }, failure: { type: 'final', entry: (context) => {console.log("FAILURE!", context)}, } } }); function useDataFetching() { const [state, send] = useMachine(dataFetchingMachine); useEffect(() => { if (state.value === 'idle') { send('FETCH'); } }, [send, state.value]); return { state: state, data: state.context.data, error: state.context.error, fetch: () => send('FETCH') //Optional to refetch data }; } export default useDataFetching; """ """jsx // MyComponent.jsx import React from 'react'; import useDataFetching from './useDataFetching'; function MyComponent() { const { state, data, error } = useDataFetching(); return ( <div> {state.value === 'loading' && <p>Loading...</p>} {state.value === 'success' && <p>Data: {data.data}</p>} {state.value === 'failure' && <p>Error: {error.message}</p>} </div> ); } export default MyComponent; """ ### 3.3. Parallel States and Complex UI Components **Standard:** Use parallel states when managing independently changing parts of a UI component. **Why:** Parallel states help to keep distinct state logic separate, improving readability and maintainability. **Do This:** """javascript import { createMachine } from 'xstate'; const multiSelectMachine = createMachine({ id: 'multiSelect', initial: 'unselected', context: { selectedItems: [] }, states: { unselected: { entry: (context) => { console.log("Unselected...", context); }, on: { SELECT: { target: 'selected', actions: assign((context, event) => { return { selectedItems: [...context.selectedItems, event.value] }; }) } } }, selected: { entry: (context) => { console.log("SELECTED!!!", context); }, on: { UNSELECT: { target: 'unselected', actions: assign((context, event) => { return { selectedItems: context.selectedItems.filter(item => item !== event.value) }; }) } } } } }); """ **Anti-pattern:** Using a single, complex state to manage multiple independent parts of a component's UI. This can lead to spaghetti code and makes it difficult to reason about the state of the component. ## 4. Testing XState Components ### 4.1. Comprehensive Unit Tests **Standard:** Write comprehensive unit tests for all state machines, actions, services, and guards. Aim for high test coverage to ensure that the application behaves as expected. **Why:** Testing improves stability of apps. **Do This:** """javascript // counterMachine.test.js import { counterMachine } from './counterMachine'; import { interpret } from 'xstate'; describe('counterMachine', () => { it('should start in the idle state', () => { const service = interpret(counterMachine).start(); expect(service.state.value).toBe('idle'); }); it('should increment the count when the INC event is sent', (done) => { const service = interpret(counterMachine) .onTransition((state) => { if (state.matches('idle') && state.context.count === 1) { expect(state.context.count).toBe(1); done(); } }) .start(); service.send('INC'); }); it('should decrement the count when the DEC event is sent', (done) => { const service = interpret(counterMachine) .onTransition((state) => { if (state.matches('idle') && state.context.count === -1) { expect(state.context.count).toBe(-1); done(); } }) .start(); service.send('DEC'); }); }); """ ### 4.2. Using "test" Property on Machines **Standard:** Use the "test" property to define testable assertions directly within the state machine definition. **Why:** Integration and reusability of tests. **Do This:** """javascript import { createMachine } from 'xstate'; const testMachine = createMachine({ id: 'testMachine', initial: 'idle', states: { idle: { on: { ACTIVATE: 'active' }, meta: { test: async ({ expect }) => { await expect('button[data-activate]').toBeVisible(); } } }, active: { on: { DEACTIVATE: 'idle' }, meta: { test: async ({ expect }) => { await expect('button[data-deactivate]').toBeVisible(); } } } } }); """ ## 5. Performance Considerations ### 5.1. Optimize Context Updates **Standard:** Minimize unnecessary context updates. Only update the context when absolutely necessary and avoid creating large, deeply nested context objects. **Why:** Performance constraints for large context objects. **Do This:** Structure data in your context efficiently. If you have large amounts of unchanging data, consider storing it outside the machine's context and referencing it as needed. ### 5.2. Lazy Loading State Machines **Standard:** Lazy-load state machine definitions to avoid loading unnecessary code upfront. This can improve the initial load time of the application. **Why:** Optimize bundle size. **Do This:** Use dynamic imports to load state machine definitions only when they are needed. """javascript // MyComponent.jsx import React, { useState, useEffect } from 'react'; import { useMachine } from '@xstate/react'; function MyComponent() { const [machine, setMachine] = useState(null); useEffect(() => { import('./myMachine') .then(module => { setMachine(module.default); }); }, []); const [state, send] = useMachine(machine); if (!machine) { return <p>Loading machine...</p>; } // ... rest of the component } """ ## 6. Security ### 6.1. Validate Input **Standard:** Validate all input received from external sources (e.g., user input, API responses) before using it in state machine actions or guards. **Why:** Ensure no XSS or malicious data. **Do This:** Use validation libraries like Yup or Zod to validate input data before updating the machine context. ### 6.2. Secure Data Handling **Standard:** Avoid storing sensitive information (e.g., passwords, API keys) in the machine context. If sensitive data needs to be handled, encrypt it before storing it in the context or use secure storage mechanisms. **Why:** Protect data and maintain security. ## 7. Documentation ### 7.1. Inline Documentation **Standard:** Provide clear and concise comments within the code to explain the purpose of states, events, actions, and services. **Why:** Improve understandability **Do This:** Comment the roles of the states used. ### 7.2. README Files **Standard:** Include README files in the module directory to explain what the machine does, how to use it, and any relevant implementation details. **Why:** Useful notes for code reviews and integration. This coding standards document provides a comprehensive guide to designing reusable, maintainable, and secure components within the XState ecosystem from an architeture standpoint. By following these guidelines, developers can create robust and scalable applications and enhance code quality, developer velocity and improve collaboration.
# State Management Standards for XState This document outlines the coding standards for state management using XState. It covers approaches to managing application state, data flow, and reactivity within XState machine definitions and implementations. These standards are geared toward the latest XState version and emphasize maintainability, performance, and security. ## 1. State Machine Definition Principles ### 1.1 Explicit State Definitions **Standard:** States should be explicitly declared with meaningful names and clear transitions. Avoid relying on implicit state transitions or string-based state comparisons outside the machine definition. **Do This:** """typescript import { createMachine } from 'xstate'; const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { idle: { on: { SELECT: { target: 'selected' } } }, selected: { on: { CONFIRM: { target: 'confirmed' }, CANCEL: { target: 'idle' } } }, confirmed: { type: 'final' } } }); """ **Don't Do This:** """typescript // Anti-pattern: Relying on string comparisons outside the machine if (currentState.value === 'idle') { // ... } """ **Why:** Explicit state definitions improve readability and make it easier to understand the flow of the application. They also allow XState's tooling (Stately Studio, visualizer) to provide better insights and validation. ### 1.2 Hierarchical (Nested) States **Standard:** Use hierarchical (nested) states to represent complex state logic and encapsulate related states. This avoids overly complex flat state machines. **Do This:** """typescript import { createMachine } from 'xstate'; const lightMachine = createMachine({ id: 'light', initial: 'inactive', states: { inactive: { on: { POWER: { target: 'active' } } }, active: { initial: 'green', states: { green: { on: { TIMER: { target: 'yellow' } } }, yellow: { on: { TIMER: { target: 'red' } } }, red: { on: { TIMER: { target: 'green' } } } }, on: { POWER: { target: 'inactive' } } } } }); """ **Don't Do This:** """typescript // Anti-pattern: Flattening all states into a single level const lightMachine = createMachine({ id: 'light', initial: 'inactive', states: { inactive: { /* ... */ }, active_green: { /* ... */ }, active_yellow: { /* ... */ }, active_red: { /* ... */ } } }); """ **Why:** Hierarchical states provide better organization and reduce complexity. They are especially useful for modeling features with their own lifecycles. ### 1.3 Parallel States **Standard:** Use parallel states to model independent concurrent processes within the application. **Do This:** """typescript import { createMachine } from 'xstate'; const multiProcessMachine = createMachine({ id: 'multiProcess', type: 'parallel', states: { processA: { initial: 'idle', states: { idle: { on: { START: { target: 'running' } } }, running: { on: { STOP: { target: 'idle' } } } } }, processB: { initial: 'stopped', states: { stopped: { on: { START: { target: 'running' } } }, running: { on: { STOP: { target: 'stopped' } } } } } } }); """ **Why:** Parallel states are essential when dealing with independent stateful logic occuring concurrently. They ensure that these processes don't interfere with each other's state. ### 1.4 Final States **Standard:** Use final states to represent the end of a specific process or lifecycle within the machine. These states can trigger "done.stateName" events, facilitating coordination between machines. **Do This:** """typescript import { createMachine } from 'xstate'; const orderMachine = createMachine({ id: 'order', initial: 'pending', states: { pending: { on: { PAY: { target: 'processing' } } }, processing: { on: { SUCCESS: { target: 'fulfilled' }, FAILURE: { target: 'rejected' } } }, fulfilled: { type: 'final' }, rejected: { type: 'final' } } }); """ **Why:** Final states clearly mark the completion of a process and serve as important signals for parent machines to react to. ## 2. Data Management (Context) ### 2.1 Context Initialization **Standard:** Initialize the machine context with sensible default values. This prevents unexpected errors when accessing context properties before they are explicitly set. **Do This:** """typescript import { createMachine } from 'xstate'; interface Context { count: number; userName: string | null; } const counterMachine = createMachine<Context>({ id: 'counter', initial: 'idle', context: { count: 0, userName: null }, states: { idle: { on: { INC: { actions: 'increment' } } } }, actions: { increment: (context) => { context.count += 1; } } }); """ **Don't Do This:** """typescript // Anti-pattern: Leaving context undefined import { createMachine } from 'xstate'; interface Context { count?: number; //Optional is BAD userName?: string | null; //Optional is BAD } const counterMachine = createMachine<Context>({ id: 'counter', initial: 'idle', context: {}, //BAD! The machine may crash if "context.count" is accessed before being defined states: { idle: { on: { INC: { actions: 'increment' } } } }, actions: { increment: (context) => { context.count += 1; //Potential Error! "context.count" might be undefined } } }); """ **Why:** Initializing context prevents "undefined" errors and improves type safety. It also clearly communicates the expected structure of the context. ### 2.2 Immutability **Standard:** Treat the context as immutable. Do not directly modify context properties within actions. Instead, return a partial context update from the "assign" action. **Do This:** """typescript import { createMachine, assign } from 'xstate'; interface Context { count: number; } const counterMachine = createMachine<Context>({ id: 'counter', initial: 'idle', context: { count: 0 }, states: { idle: { on: { INC: { actions: 'increment' } } } }, actions: { increment: assign({ count: (context) => context.count + 1 }) } }); """ **Don't Do This:** """typescript // Anti-pattern: Directly modifying the context import { createMachine } from 'xstate'; interface Context { count: number; } const counterMachine = createMachine<Context>({ id: 'counter', initial: 'idle', context: { count: 0 }, states: { idle: { on: { INC: { actions: 'increment' } } } }, actions: { increment: (context) => { context.count += 1; //BAD: Mutates Context! } } }); """ **Why:** Immutability ensures predictable state transitions and simplifies debugging. It also optimizes performance by allowing XState to efficiently detect changes. Using "assign" triggers the proper notifications and re-renders in connected components. ### 2.3 Context Typing **Standard:** Use TypeScript to define the structure of the context. This enforces type safety and helps prevent runtime errors related to incorrect data types. **Do This:** """typescript import { createMachine } from 'xstate'; interface Context { userName: string; age: number; isActive: boolean; } const userMachine = createMachine<Context>({ id: 'user', initial: 'active', context: { userName: 'John Doe', age: 30, isActive: true }, states: { active: { // ... } } }); """ **Why:** Proper typing catches errors early, improves code maintainability, and facilitates refactoring. ## 3. Actions and Effects ### 3.1 Action Naming **Standard:** Use descriptive names for actions that clearly indicate their purpose. **Do This:** """typescript // Clear and descriptive action names const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { idle: { on: { SELECT: { target: 'selected', actions: 'loadAvailableRooms' } } }, selected: { //... entry: 'sendConfirmationEmail' } }, actions: { loadAvailableRooms: () => { /* ... */ }, sendConfirmationEmail: () => { /* ... */ } } }); """ **Don't Do This:** """typescript // Anti-pattern: Vague action names const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { idle: { on: { SELECT: { target: 'selected', actions: 'action1' } } }, selected: { //... entry: 'action2' } }, actions: { action1: () => { /* ... */ }, action2: () => { /* ... */ } } }); """ **Why:** Descriptive action names make the machine definition easier to understand and maintain. ### 3.2 Side Effects **Standard:** Isolate side effects within actions or services. Avoid performing side effects directly within guards, as guards should be pure functions. **Do This:** """typescript // Correct: Side effect in an action import { createMachine, assign } from 'xstate'; interface Context { orderId: string | null; } const orderMachine = createMachine<Context>({ id: 'order', initial: 'idle', context: { orderId: null }, states: { idle: { on: { CREATE_ORDER: { target: 'creating', actions: 'createOrder' } } }, creating: { invoke: { src: 'createOrderService', onDone: { target: 'active', actions: 'assignOrderId' }, onError: { target: 'failed'} } }, active: { }, failed: {} }, actions: { assignOrderId: assign({ orderId: (context, event) => event.data.orderId //Assuming createOrderService returns { orderId: string } }) }, services: { createOrderService: async () => { //Async function MUST be performed here const response = await fetch('/api/create-order', {method: 'POST'}); return await response.json(); } } }); """ **Don't Do This:** """typescript // Anti-pattern: Side effect in a guard const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { idle: { on: { SELECT: { target: 'selected', cond: () => { // BAD! Side effect in a guard. console.log('Checking availability'); return true; } } } } } }); """ **Why:** Isolating side effects makes the code easier to test and reason about. Guards should only perform pure checks on the context and event data. Place asynchronous functions in "services" blocks. ### 3.3 Reusability **Standard:** Define reusable actions and services that can be used across multiple machines or states. Avoid duplicating logic within the machine definition. **Do This:** """typescript // Reusable service definition const sendEmail = async (context, event) => { // ... send email logic }; const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { //... }, invoke: { id: 'emailConfirmation', src: sendEmail, onDone: { target: 'confirmed'} } }); const userMachine = createMachine({ id: 'user', initial: 'active', states: { //... }, invoke: { id: 'welcomeEmail', src: sendEmail, onDone: { target: 'active'} } }); """ **Why:** Reusability reduces code duplication and improves maintainability. Services are best declared outside the machine definition, promoting separation of concerns, and enabling easy unit testing. ### 3.4 Error Handling **Standard:** Implement robust error handling for services. Use the "onError" transition to handle errors and transition the machine to an error state. **Do This:** """typescript import { createMachine } from 'xstate'; const paymentMachine = createMachine({ id: 'payment', initial: 'idle', states: { idle: { on: { PAY: { target: 'processing' } } }, processing: { invoke: { src: 'processPayment', onDone: { target: 'success' }, onError: { target: 'failure', actions: 'logError' } } }, success: { type: 'final' }, failure: { on: { RETRY: { target: 'processing' } } } }, actions: { logError: (context, event) => { console.error('Payment failed:', event.data); } }, services: { processPayment: async (context, event) => { // ... throw new Error('Payment processing failed'); // Simulate an error } } }); """ **Why:** Proper error handling ensures the application gracefully handles unexpected situations and provides useful feedback to the user. ### 3.5 Event Handling **Standard:** Use "raise" to send internal events within the machine that can trigger transitions or actions. This promotes better encapsulation and prevents external components from directly manipulating the machine's state. """typescript import { createMachine, raise } from 'xstate'; const timerMachine = createMachine({ id: 'timer', initial: 'running', context: { remaining: 10 }, states: { running: { after: { 1000: { actions: [ 'decrement', raise({ type: 'TICK' }) ] } }, on: { TICK: { actions: (context) => { if (context.remaining <= 0) { return raise({type: 'TIMEOUT'}) } } }, TIMEOUT: {target: 'expired'} } }, expired: {type: 'final'} }, actions: { decrement: (context) => { context.remaining-- } } }); """ **Why:** "raise" ensures that state transitions are triggered by the machine itself, maintaining control over its internal logic. "raise" can conditionally dispatch different events in Actions. ## 4. Guards (Conditions) ### 4.1 Pure Functions **Standard:** Guards must be pure functions. They should only depend on the context and event data and should not have any side effects. **Do This:** """typescript import { createMachine } from 'xstate'; interface Context { age: number; } const userMachine = createMachine<Context>({ id: 'user', initial: 'inactive', context: { age: 25 }, states: { inactive: { on: { ACTIVATE: { target: 'active', cond: (context) => context.age >= 18 } } }, active: { /* ... */ } } }); """ **Don't Do This:** """typescript // Anti-pattern: Guard with side effect const userMachine = createMachine({ id: 'user', initial: 'inactive', states: { inactive: { on: { ACTIVATE: { target: 'active', cond: (context) => { console.log('Checking age'); // Side effect! return context.age >= 18; } } } }, active: { /* ... */ } } }); """ **Why:** Pure guards ensure predictable behavior and make the machine easier to test. Side effects in guards can lead to unexpected state transitions and make debugging difficult. ### 4.2 Guard Naming **Standard:** Use descriptive names for guards that clearly indicate the condition being evaluated. **Do This:** """typescript const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { idle: { on: { SELECT: { target: 'selected', cond: 'isRoomAvailable' } } } }, guards: { isRoomAvailable: (context, event) => { //Implementation of availability check return true; } } }); """ **Don't Do This:** """typescript // Anti-pattern: Vague guard name const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { idle: { on: { SELECT: { target: 'selected', cond: 'condition1' } } } }, guards: { condition1: (context, event) => { // ... return true; } } }); """ **Why:** Descriptive guard names improve readability. ## 5. Invoked Services (Actors) ### 5.1 Service Definition **Standard:** Define services in the "invoke" property of a state. Use the "src" property to specify the service's implementation. **Do This:** """typescript import { createMachine } from 'xstate'; const dataFetchMachine = createMachine({ id: 'dataFetch', initial: 'idle', states: { idle: { on: { FETCH: { target: 'loading' } } }, loading: { invoke: { id: 'fetchData', src: 'fetchDataService', onDone: { target: 'success', actions: 'assignData' }, onError: { target: 'failure', actions: 'assignError' } } }, success: { /* ... */ }, failure: { /* ... */ } }, actions: { assignData: assign({ data: (context, event) => event.data }), assignError: assign({ error: (context, event) => event.data }) }, services: { fetchDataService: async (context, event) => { const response = await fetch('/api/data'); return await response.json(); } } }); """ **Why:** This structure clearly defines the service and its lifecycle within the state machine. ### 5.2 Actor Communication **Standard:** Use the "send" action or the "sendParent" to communicate with invoked actors (services) or the parent machine, respectively. Ensure there is a clear protocol for communication between actors and their parent machines. **Do This:** """typescript // Example: Sending an event to a child service. import { createMachine, send } from 'xstate'; const childMachine = createMachine({ id: 'child', initial: 'idle', states: { idle: { on: { REQUEST_DATA: { target: 'loading' } } }, loading: { after: { 5000: {target: 'idle'} }, on: { DATA_RECEIVED: { target: 'idle' }, TIMEOUT: { target: 'idle' } } } } }); const parentMachine = createMachine({ id: 'parent', initial: 'active', states: { active: { invoke: { id: 'childService', src: childMachine }, entry: send({ type: 'REQUEST_DATA' }, { to: 'childService' }), on: { '*': { actions: (context, event) => { console.log('Parent received:', event.type); } } } } } } ); """ **Why:** Clear communication protocols are essential for managing complex interactions between machines. Use explicit event types and data structures. Avoid tightly coupled machine definition. ### 5.3 Cleanup **Standard:** Implement cleanup logic within services to release resources or cancel ongoing operations when the service is interrupted or completes. Use the "onDone" and "onError" transitions to perform cleanup actions. **Example:** Leveraging the "aborted" signal for cleanup: """typescript import { createMachine, assign } from 'xstate'; const fileUploadMachine = createMachine({ id: 'fileUpload', initial: 'idle', context: { uploadProgress: 0, controller: null }, states: { idle: { on: { UPLOAD: { target: 'uploading', actions: 'createAbortController' } } }, uploading: { invoke: { id: 'uploadFile', src: 'uploadFileService', onDone: 'success', onError: 'failure' }, on: { CANCEL: { target: 'idle', actions: 'abortUpload' } } }, success: { type: 'final' }, failure: { type: 'final' } }, actions: { createAbortController: assign({ controller: () => new AbortController() }), abortUpload: (context) => { context.controller.abort(); } }, services: { uploadFileService: async (context, event) => { const file = event.file; const controller = context.controller; const formData = new FormData(); formData.append('file', file); const response = await fetch('/api/upload', { method: 'POST', body: formData, signal: controller.signal // Pass the abort signal }); return response.json(); } } }); """ **Why:** Proper cleanup avoids memory leaks, prevents resource exhaustion, and ensures the application remains stable. AbortController usage specifically for canceling ongoing asynchronous requests is an important web platform feature. ## 6. Testing ### 6.1 Unit Testing **Standard:** Write unit tests to verify the behavior of individual states, transitions, actions, and guards within the machine. **Example:** """typescript import { interpret } from 'xstate'; import { bookingMachine } from './bookingMachine'; // Assuming bookingMachine is in a separate file describe('bookingMachine', () => { it('should transition from idle to selected on SELECT event', (done) => { const service = interpret(bookingMachine).onTransition((state) => { if (state.value === 'selected') { expect(state.value).toBe('selected'); done(); } }); service.start(); service.send({ type: 'SELECT' }); }); }); """ ### 6.2 Integration Testing **Standard:** Write integration tests to verify the interaction between multiple machines or between the machine and external systems. **Why:** Testing ensures the correct behavior of state machines and helps prevent regressions. Focus on testing state transitions, actions and conditional flows. ## 7. Tooling & Conventions ### 7.1 Stately Studio **Standard:** Use Stately Studio (stately.ai) to visually design, analyze, and test state machines. Leverage Stately Studio's code generation capabilities to create XState machine definitions. ### 7.2 Visualizer **Standard:** Use the XState visualizer (https://stately.ai/viz) to visualize the state machine and understand its behavior. Regularly visualize the machine to confirm its design meets the required functionality. ### 7.3 Code Generation **Standard:** When possible, use code generation tools (e.g., Stately Studio) to automatically generate XState machine definitions. This can reduce errors and ensure consistency. ### 7.4 Machine IDs **Standard:** Provide unique and descriptive IDs for all machines. It is MANDATORY to use IDs other invoke calls will fail, and it also supports a more visual and easier debugging journey. """javascript invoke: { id: 'emailConfirmation', // Mandatory, for tracking and linking events src: sendEmail, onDone: { target: 'confirmed'} } """ Adhering to these standards will result in more maintainable, performant, and secure XState applications. This document should be regularly reviewed and updated to reflect the latest best practices and features of XState.
# Testing Methodologies Standards for XState This document outlines the recommended testing methodologies for XState state machines and actors. It covers strategies for unit, integration, and end-to-end testing, with a focus on best practices for the latest version of XState. Following these standards ensures maintainability, reliability, and correctness of XState-based applications. ## 1. General Testing Principles ### 1.1. Test Pyramid * **Do This:** Adhere to the test pyramid, prioritizing unit tests, followed by integration tests, and then end-to-end tests. * **Don't Do This:** Over-rely on end-to-end tests at the expense of unit and integration tests. This leads to brittle and slow test suites. **Why?** A balanced test pyramid ensures faster feedback loops (unit tests), comprehensive coverage (integration tests), and realistic user flow validation (end-to-end tests). ### 1.2. Test-Driven Development (TDD) * **Do This:** Consider using TDD to drive the design of your state machines. Write a test for a transition or guard before implementing it. * **Don't Do This:** Implement complex state machine logic without first defining the expected behavior through tests. **Why?** TDD encourages a clear understanding of requirements and results in more testable and modular state machine designs. ### 1.3. Test Coverage * **Do This:** Aim for high test coverage of all states, transitions, guards, and actions within your state machines. Use code coverage tools to identify gaps in your tests. * **Don't Do This:** Assume that achieving 100% coverage guarantees bug-free code. Focus on testing critical paths and edge cases. **Why?** Test coverage is a metric that provides a measure of how much of the codebase is exercised by tests. High coverage reduces the risk of regressions and ensures a more stable application. ### 1.4. Test Isolation * **Do This:** Isolate state machine tests by mocking external dependencies (services, API calls, etc.). * **Don't Do This:** Allow external dependencies to directly influence the outcome of state machine tests. This can lead to flaky tests and makes debugging difficult. **Why?** Isolation ensures that tests are deterministic and that failures are directly attributable to issues within the state machine. ### 1.5. Clear and Readable Tests * **Do This:** Write tests that are easy to understand and maintain. Use descriptive test names and comments to explain the purpose of each test. * **Don't Do This:** Write overly complex or cryptic tests that are difficult to understand or debug. **Why?** Clear and readable tests enable easier collaboration and make it easier to identify the cause of failures. ## 2. Unit Testing ### 2.1. Focus * **Do This:** Unit tests should focus on verifying the behavior of individual states, transitions, guards, and actions within a state machine. * **Don't Do This:** Treat unit tests as integration tests by including interactions with external systems. **Why?** Unit tests provide rapid feedback and isolate issues to specific parts of the state machine. ### 2.2. State Transition Testing * **Do This:** Create tests that verify that the state machine transitions to the correct state in response to various events. """typescript import { createMachine } from 'xstate'; import { createModel } from '@xstate/test'; const bookingMachine = createMachine({ id: 'booking', initial: 'idle', states: { idle: { on: { START: { target: 'pending', actions: ['startBooking'], }, }, }, pending: { on: { RESOLVE: { target: 'success', actions: ['resolveBooking'], }, REJECT: { target: 'failure', actions: ['rejectBooking'], }, }, }, success: { type: 'final', }, failure: { on: { RETRY: 'pending', }, }, }, }, { actions: { startBooking: () => { console.log('Starting booking'); }, resolveBooking: () => { console.log('Resolving booking'); }, rejectBooking: () => { console.log('Rejecting booking'); }, } }); const bookingModel = createModel(bookingMachine).withEvents({ START: {}, RESOLVE: {}, REJECT: {}, RETRY: {} }); describe('Booking Machine', () => { const testPlans = bookingModel.getShortestPathPlans(); testPlans.forEach((plan) => { describe(plan.description, () => { plan.paths.forEach((path) => { it(path.description, async () => { await path.test(); }); }); }); }); it('should have complete coverage', () => { bookingModel.getAllStates().forEach((state) => { expect(state.properties).not.toBeUndefined(); }); }); }); """ * **Don't Do This:** Only test a subset of transitions, leaving edge cases and error scenarios untested. **Why?** Thorough state transition testing ensures predictable and reliable behavior of the state machine. The "createModel" and "@xstate/test" tools are optimal for state machine testing. ### 2.3. Guard Condition Testing * **Do This:** Write tests to verify that guard conditions correctly determine whether a transition can occur. """typescript import { createMachine } from 'xstate'; import { createModel } from '@xstate/test'; const authMachine = createMachine({ id: 'auth', initial: 'loggedOut', context: { isAdmin: false }, states: { loggedOut: { on: { LOGIN: { target: 'loggedIn', guard: 'isAdmin' } } }, loggedIn: { type: 'final' } } }, { guards: { isAdmin: (context) => context.isAdmin } }); const authModel = createModel(authMachine).withEvents({ LOGIN: {} }); describe('Auth Machine', () => { it('should not transition to loggedIn if not admin', async () => { const testMachine = authMachine.withContext({ isAdmin: false }); const snapshot = testMachine.transition('loggedOut', { type: 'LOGIN' }); expect(snapshot.changed).toBe(false); expect(snapshot.value).toBe('loggedOut'); }); it('should transition to loggedIn if admin', async () => { const testMachine = authMachine.withContext({ isAdmin: true }); const snapshot = testMachine.transition('loggedOut', { type: 'LOGIN' }); expect(snapshot.changed).toBe(true); expect(snapshot.value).toBe('loggedIn'); }); }); """ * **Don't Do This:** Neglect to test guard conditions, potentially allowing invalid transitions to occur. **Why?** Guard conditions enforce business rules and constraints. Testing them ensures that these rules are correctly implemented. ### 2.4. Action Testing * **Do This:** Verify that actions are executed correctly when transitions occur. Use mocks or spies to track action invocations. """typescript import { createMachine, assign } from 'xstate'; import { createModel } from '@xstate/test'; import { vi, describe, it, expect } from 'vitest' const sendEmail = vi.fn(); const emailMachine = createMachine({ id: 'email', initial: 'idle', context: { emailAddress: '' }, states: { idle: { on: { SEND: { target: 'sending', actions: 'setEmailAddress' } } }, sending: { entry: 'sendEmail', type: 'final' } } }, { actions: { setEmailAddress: assign({ emailAddress: (context, event) => event.email }), sendEmail: sendEmail } }); describe('Email Machine', () => { it('should execute the sendEmail action when transitioning to the sending state', () => { const testMachine = emailMachine.withContext({ emailAddress: "test@example.com" }); testMachine.transition('idle', { type: 'SEND', email: 'test@example.com' }); expect(sendEmail).toHaveBeenCalledTimes(1); }); }); """ * **Don't Do This:** Skip testing actions, leaving the side effects of transitions unverified. **Why?** Actions perform side effects, such as updating application state or interacting with external systems. Testing them ensures that these effects occur as expected. ### 2.5. Context Testing * **Do This:** Ensure the context transitions as expected * **Don't do This:** Skip context changes, especially computed contexts """typescript import { createMachine, assign } from 'xstate'; import { createModel } from '@xstate/test'; import { vi, describe, it, expect } from 'vitest' const userMachine = createMachine({ /** @xstate-layout N4IgpgJg5mIHgTgCwJYIDqgJwgOQLrmgNoAMAuwDUAlhANoB0AVAHQEsA7MAJwFUAZQDsdQAJgAtoaAA9EARgCsAdgAYjACgBMAFgBtAAwB2ALgDMAdl0ALAHYAjAGFjZq3oAYAHccOQ0gAHCn70AazkEcgB2IJS0DByCAO4AzAAsczZwAC4AKgAqA5QANH4A7kFQwHls4gBKMkSkrGz+2JzU9Iy4+ISk4pKSsnKSygLKyqr2iY7oQY0b2Wl4AdgBiAHIAUgBqU5Y6AEEs4uEAOiD0zZgYwZg4c05O2m53b7eUoK0lC1w+M6Q61e4S5pE4S06PqRQKZROAAXQ4wB0sBAchg4hA4YlEqlA1UqhUOhUOq3K43e40s22jU4QYyQ09t0kAAeXQ6AAUwG8M4w5gAUnk+gApIArYg4jicLgAnAATVgoNAgY5rIAKj2s0m1EokqYn5M77i4mUq2u60y00y9o9kL1Fp5W11h5SIA */ id: 'user', initial: 'idle', context: { age: 0 }, states: { idle: { on: { 'USER.SET_AGE': { actions: assign({ age: (context, event) => event.age }), target: 'idle', description: 'Sets the age of the user' } } } } }) describe('User Machine', () => { it('should update the age', () => { const testMachine = userMachine.withContext({ age: 20 }); const snapshot = testMachine.transition('idle', { type: 'USER.SET_AGE', age: 30 }); expect(snapshot.context.age).toBe(30); }); }); """ **Why:** The context of XState machines is a crucial element for managing state and data within the machine. Thoroughly testing context transitions ensures that data updates, state-dependent logic, and machine behavior are predictable and reliable. Neglecting context testing leads to unreliable applications and undermines the integrity of state management ## 3. Integration Testing ### 3.1. Focus * **Do This:** Integration tests should focus on verifying the interactions between the state machine and its dependencies – user interfaces, external services, or other parts of the application. * **Don't Do This:** Test the internal logic of the state machine in integration tests. This is the responsibility of unit tests. **Why?** Integration tests ensure that the state machine is correctly integrated into the larger application. ### 3.2. UI Integration * **Do This:** Use UI testing frameworks (e.g., Cypress, Jest with React Testing Library) to verify that the UI updates correctly in response to state changes in the state machine. * **Don't Do This:** Manually verify UI updates, as this is time-consuming and error-prone. **Why?** UI integration tests ensure that the user interface accurately reflects the state of the application. ### 3.3. Service Integration * **Do This:** Mock external services to prevent integration tests from depending on the availability of these services. Use tools like "nock" or "msw" to mock HTTP requests. * **Don't Do This:** Directly call external services in integration tests, as this can lead to flaky tests and obscure issues within the state machine. **Why?** Service integration tests verify that the state machine correctly interacts with external systems. ### 3.4. Example """typescript // Example using React Testing Library and mocked service import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMachine } from 'xstate'; import { useMachine } from '@xstate/react'; import React from 'react'; import * as msw from 'msw'; import * as mswNode from 'msw/node'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; // Define a simple component that uses the state machine const MyComponent = () => { const paymentMachine = createMachine({ id: 'payment', initial: 'idle', context: { paymentId: null, error: null, }, states: { idle: { on: { INITIATE: 'pending', }, }, pending: { invoke: { id: 'createPayment', src: async () => { const response = await fetch('/payment', { method: 'POST' }); if (!response.ok) { throw new Error('Failed to create payment'); } const data = await response.json(); return data; }, onDone: { target: 'success', actions: assign({ paymentId: (_context, event) => event.data.id, }), }, onError: { target: 'failure', actions: assign({ error: (_context, event) => event.data.message, }), }, }, }, success: { type: 'final', }, failure: { on: { RETRY: 'pending', }, }, }, }); const [state, send] = useMachine(paymentMachine); return ( <div> {state.matches('idle') && ( <button onClick={() => send({ type: 'INITIATE' })}>Initiate Payment</button> )} {state.matches('pending') && <p>Creating payment...</p>} {state.matches('success') && <p>Payment successful! Payment ID: {state.context.paymentId}</p>} {state.matches('failure') && ( <> <p>Payment failed: {state.context.error}</p> <button onClick={() => send({ type: 'RETRY' })}>Retry</button> </> )} </div> ); }; const server = mswNode.setupServer( msw.rest.post('/payment', (req, res, ctx) => { return res(ctx.json({ id: '12345' })); }), ); beforeAll(() => server.listen()); afterAll(() => server.close()); msw.afterEach(() => server.resetHandlers()); describe('MyComponent', () => { it('should initiate payment and display success message', async () => { render(<MyComponent />); userEvent.click(screen.getByText('Initiate Payment')); await waitFor(() => screen.getByText('Payment successful! Payment ID: 12345')); expect(screen.getByText('Payment successful! Payment ID: 12345')).toBeInTheDocument(); }); it('should handle payment failure and display error message', async () => { server.use( msw.rest.post('/payment', (req, res, ctx) => { return res(ctx.status(500), ctx.json({ message: 'Payment failed' })); }), ); render(<MyComponent />); userEvent.click(screen.getByText('Initiate Payment')); await waitFor(() => screen.getByText('Payment failed: Payment failed')); expect(screen.getByText('Payment failed: Payment failed')).toBeInTheDocument(); userEvent.click(screen.getByText('Retry')); await waitFor(() => screen.getByText('Payment successful! Payment ID: 12345')); expect(screen.getByText('Payment successful! Payment ID: 12345')).toBeInTheDocument(); }); }); """ ## 4. End-to-End (E2E) Testing ### 4.1. Focus * **Do This:** E2E tests should focus on verifying critical user flows through the entire application, including the state machine, UI, and backend services. * **Don't Do This:** Use E2E tests to cover every possible scenario. Focus on the most important user journeys. **Why?** E2E tests ensure that the entire application works correctly from the user's perspective. ### 4.2. Tools * **Do This:** Use E2E testing frameworks like Cypress, Playwright, or Puppeteer to automate user interactions and verify application behavior. * **Don't Do This:** Rely on manual testing for critical user flows. **Why?** Automated E2E tests provide consistent and reliable validation of application functionality. ### 4.3. Data Setup * **Do This:** Set up test data before running E2E tests to ensure a consistent and predictable testing environment. Use database seeding or API calls to create the necessary data. * **Don't Do This:** Assume that the application starts in a known state. **Why?** Consistent test data prevents E2E tests from failing due to inconsistent application state. ### 4.4. Example with Cypress """javascript // cypress/e2e/booking.cy.js describe('Booking Flow', () => { it('should book a flight successfully', () => { cy.visit('/booking'); // Mock the API request using cy.intercept cy.intercept('POST', '/api/flights', { fixture: 'flights.json', // Load mock data from a fixture file }).as('getFlights'); cy.get('[data-testid="departure-city"]').select('London'); cy.get('[data-testid="arrival-city"]').select('New York'); cy.get('[data-testid="date"]').type('2024-01-15'); cy.get('button[type="submit"]').click(); cy.wait('@getFlights'); // Wait for the mocked API call to complete cy.get('[data-testid="flight-list-item"]').should('have.length.gt', 0); cy.get('[data-testid="book-flight-button"]').first().click(); cy.get('[data-testid="confirmation-message"]').should('contain', 'Booking confirmed!'); }); }); // cypress/fixtures/flights.json [ { "id": "1", "departureCity": "London", "arrivalCity": "New York", "date": "2024-01-15", "price": 500 }, { "id": "2", "departureCity": "London", "arrivalCity": "New York", "date": "2024-01-15", "price": 600 } ] """ ### 4.5. Test Stability * Do This: Add retry logic and timeouts when interacting with elements in the UI to account for potential delays or loading times. * Don't do this: Rely on hardcoded delays (e.g., "setTimeout") as they can make tests slow and are not reliable. **Why**: UI interactions might experience variability in timing, leading to transient test failures. Retry logic and timeouts make end-to-end (E2E) tests more robust by allowing the test to wait and retry interactions when elements are not immediately available or when asynchronous processes are in progress. ## 5. XState Test Model ### 5.1. Overview * **Do This:** Utilize the "@xstate/test" library with "createModel" to automatically generate test plans from your state machine definitions. * **Don't Do This:** Manually write individual test cases for every state and event combination. **Why?** "createModel" automates test generation, ensuring comprehensive coverage and reducing the risk of missing critical scenarios. ### 5.2. Example """typescript import { createMachine } from 'xstate'; import { createModel } from '@xstate/test'; const lightMachine = createMachine({ id: 'light', initial: 'green', states: { green: { on: { TIMER: 'yellow' } }, yellow: { on: { TIMER: 'red' } }, red: { on: { TIMER: 'green' } } } }); const lightModel = createModel(lightMachine, { events: { TIMER: () => ({}) } }); const testPlans = lightModel.getShortestPathPlans(); testPlans.forEach((plan) => { describe(plan.description, () => { plan.paths.forEach((path) => { it(path.description, async () => { await path.test(); }); }); }); }); it('should have complete coverage', () => { lightModel.getAllStates().forEach((state) => { expect(state.properties).not.toBeUndefined(); }); }); """ ### 5.3. Custom Assertions * Do This: Customize assertions to add specific checks that ensure both transition and internal state changes match expectations. * Don't Do This: Omit assertions, relying solely on state transitions, which can lead to overlooking subtle errors within the machine's logic. **Why**: Thoroughly testing internal changes and final states enhances the robustness of state machine tests, ensuring the machine behaves correctly under various conditions and that its outputs meet expected criteria. Explicit assertions help catch unexpected behavior and state corruption early, leading to more reliable and maintainable systems. ## 6. Visual Inspection and Debugging ### 6.1. State Visualizer * **Do This:** Use the XState Visualizer (Stately Studio) to visually inspect the state machine and understand its behavior. * **Don't Do This:** Rely solely on code inspection, as this can be difficult for complex state machines. **Why?** The visualizer provides a clear and intuitive representation of the state machine, making it easier to identify potential issues. ### 6.2. Debugging * **Do This:** Use debugging tools (e.g., "console.log", browser debugger) to trace the execution of state machine actions and transitions. * **Don't Do This:** Rely solely on error messages, as they may not provide sufficient information to diagnose the root cause of issues. **Why?** Debugging tools provide detailed insights into the behavior of the state machine, making it easier to identify and fix issues. Also use event listeners in inspector. ## 7. Performance Testing Considerations ### 7.1. Bottlenecks * **Do This:** Identify potential performance bottlenecks within state machine actions, especially those that involve complex computations or I/O operations. * **Don't Do This:** Assume that the state machine itself is always the cause of performance issues. **Why?** Performance bottlenecks can degrade the responsiveness and scalability of the application. ### 7.2. Load Testing * **Do This:** Perform load testing to assess the performance of the state machine under heavy load. Use tools like "k6" or "artillery" to simulate concurrent users and events. * **Don't Do This:** Neglect to test the performance of the state machine under realistic load conditions. **Why?** Load testing identifies performance limitations and helps ensure that the application can handle expected traffic. ### 7.3. Optimization * **Do This:** Optimize performance bottlenecks by caching results, using more efficient algorithms, or offloading work to background tasks. * **Don't Do This:** Prematurely optimize the state machine without first identifying and measuring performance bottlenecks. **Why?** Performance optimization improves the responsiveness and scalability of the application. ## 8. Security Considerations ### 8.1. Input Validation * **Do This:** Validate all inputs to the state machine, including events and context data, to prevent malicious or invalid data from entering the system and causing unexpected behavior. * **Don't Do This:** Trust that inputs are always valid. Always validate data before using it. **Why?** Input validation protects against security vulnerabilities such as injection attacks and denial-of-service attacks. ### 8.2. Access Control * **Do This:** Enforce access control policies within the state machine to restrict access to sensitive states, transitions, or actions based on user roles or permissions. * **Don't Do This:** Allow unauthorized users to trigger sensitive transitions or access sensitive data. **Why?** Access control protects against unauthorized access and data breaches. ### 8.3. Error Handling * **Do This:** Implement robust error handling within the state machine to gracefully handle unexpected errors or exceptions and prevent them from crashing the application or exposing sensitive information. * **Don't Do This:** Silently ignore errors or allow them to bubble up to the user interface. **Why?** Proper error handling improves the reliability and security of the application. By adhering to these testing methodologies, development teams can ensure the quality, reliability, and security of XState-based applications. This comprehensive approach encompasses unit, integration, and end-to-end testing, leveraging the XState ecosystem's tools and libraries for optimal results.
# API Integration Standards for XState This document outlines coding standards for integrating XState state machines with backend services and external APIs. It is intended to provide a consistent and maintainable approach across all XState API integrations. ## 1. General Principles ### 1.1. Separation of Concerns **Do This:** Isolate API logic from state machine logic as much as possible. **Don't Do This:** Directly embed API calls within state actions or guards without abstraction. **Why:** Separating concerns makes the state machine more readable, testable, and maintainable. Changes to the API implementation won't necessitate changes to the state machine's core logic. **Example:** """typescript // Anti-pattern: Tightly coupled API call import { createMachine, assign } from 'xstate'; const userMachine = createMachine({ id: 'user', initial: 'idle', context: { userData: null, error: null, }, states: { idle: { on: { FETCH: { target: 'loading', }, }, }, loading: { invoke: { id: 'fetchUser', src: (context) => { return fetch('/api/user') .then((response) => response.json()) .catch((error) => { console.error("API Error:", error); throw error; // Re-throw to reject the promise }); }, onDone: { target: 'success', actions: assign({ userData: (context, event) => event.data }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: { type: 'final', }, failure: { on: { RETRY: 'loading', }, }, }, }); // Better pattern: Decoupled API call using a service import { createMachine, assign } from 'xstate'; // API service abstraction const fetchUserService = (context) => { return fetch('/api/user') .then((response) => response.json()) .catch((error) => { console.error("API Error:", error); throw error; // This is crucial for onError to work correctly. }); }; const userMachine = createMachine({ id: 'user', initial: 'idle', context: { userData: null, error: null, }, states: { idle: { on: { FETCH: { target: 'loading', }, }, }, loading: { invoke: { id: 'fetchUser', src: fetchUserService, onDone: { target: 'success', actions: assign({ userData: (context, event) => event.data }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: { type: 'final', }, failure: { on: { RETRY: 'loading', }, }, }, }); """ ### 1.2. Declarative over Imperative **Do This:** Define API integration declaratively using "invoke" and "actions". **Don't Do This:** Mutate state directly within API callbacks or side effects outside of XState’s control. **Why:** Declarative approaches lead to more predictable and testable state management because XState controls all state mutations. Imperative approaches can introduce race conditions, unexpected state transitions, and make testing cumbersome, violating the fundamental principles. **Example:** """typescript // Anti-pattern: Imperative state mutation outside of state machine context let externalUserData = null; const userMachine = createMachine({ id: 'user', initial: 'idle', states: { idle: { on: { FETCH: { target: 'loading', }, }, }, loading: { entry: () => { fetch('/api/user') .then((response) => response.json()) .then((data) => { externalUserData = data; // Direct mutation of external variable - BAD }); }, after: { 3000: 'success', }, }, success: { type: 'final', }, }, }); // Correct pattern: Declarative state updates via assign action import { createMachine, assign } from 'xstate'; const userMachine = createMachine({ id: 'user', initial: 'idle', context: { userData: null, }, states: { idle: { on: { FETCH: { target: 'loading', }, }, }, loading: { invoke: { id: 'fetchUser', src: () => fetch('/api/user').then((response) => response.json()), onDone: { target: 'success', actions: assign({ userData: (context, event) => event.data }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: { type: 'final' }, failure: { on: { RETRY: 'loading', }, }, }, }); """ ### 1.3. Context Management **Do This:** Store API-related data (e.g., request parameters, response data, error messages) in the state machine's context. **Don't Do This:** Use global variables or component-level state outside of the context. **Why:** Centralized context simplifies debugging, testing, and state hydration/rehydration. It adheres to the single source of truth principle. **Example:** """typescript // Anti-pattern: Using an external variable as flag let isSaving = false; const saveMachine = createMachine({ id: 'save', initial: 'idle', states: { idle: { on: { SAVE: { target: 'saving', actions: () => { isSaving = true; // external mutation setTimeout(() => { isSaving = false; //external mutation; }, 2000); } } } }, saving: { after: { 2000: "saved" } }, saved: { type: 'final' } } }) //DO This: import { createMachine, assign } from 'xstate'; const saveMachine = createMachine({ id: 'save', initial: 'idle', context: { isSaving: false }, states: { idle: { on: { SAVE: { target: 'saving', actions: assign({ isSaving: true }) } } }, saving: { after: { 2000: "saved" }, exit: assign({ isSaving: false }) }, saved: { type: 'final' } } }) """ ## 2. API Service Invocation ### 2.1. Using "invoke" **Do This:** Use the "invoke" property to define API service invocations. Configure "onDone" and "onError" for handling successful responses and errors. Provide unique "id" for each Invoke configuration. **Don't Do This:** Call APIs directly within action functions or guards without using "invoke", as this bypasses XState's lifecycle management features. **Why:** "invoke" manages the lifecycle of the promise, including cancellation, loading states, and error handling. **Example:** """typescript import { createMachine, assign } from 'xstate'; const todoMachine = createMachine({ id: 'todo', initial: 'idle', context: { todos: [], error: null, }, states: { idle: { on: { LOAD: { target: 'loading', }, }, }, loading: { invoke: { id: 'loadTodos', src: () => fetch('/api/todos').then((response) => response.json()), onDone: { target: 'loaded', actions: assign({ todos: (context, event) => event.data }), }, onError: { target: 'failed', actions: assign({ error: (context, event) => event.data }), }, }, }, loaded: { type: 'final', }, failed: { on: { RETRY: 'loading', }, }, }, }); """ ### 2.2. Error Handling **Do This:** Implement robust error handling for all API requests. Use "onError" in "invoke" to transition to an error state and store the error in the context. Consider global error handling mechanisms for unrecoverable errors. Ensure that "src" throws the error. **Don't Do This:** Ignore potential API errors or let them crash the application. **Why:** Proper error handling improves the user experience, allows for graceful recovery, and aids in debugging. **Example:** """typescript import { createMachine, assign } from 'xstate'; const userMachine = createMachine({ id: 'user', initial: 'idle', context: { userData: null, error: null, }, states: { idle: { on: { FETCH: { target: 'loading', }, }, }, loading: { invoke: { id: 'fetchUser', src: () => fetch('/api/user') .then((response) => { if (!response.ok) { throw new Error("HTTP error! status: ${response.status}"); } return response.json(); }) .catch((error) => { console.error("API Error:", error); // Log the error throw error; // Re-throw the error to trigger onError }), onDone: { target: 'success', actions: assign({ userData: (context, event) => event.data }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: { type: 'final', }, failure: { on: { RETRY: 'loading', }, }, }, }); """ ### 2.3. Cancellation **Do This:** Utilize the "invoke" feature. When transitioning out of a "loading" state that initiated an API request, XState automatically cancels the invoked service (the promise). Ensure API requests support cancellation (e.g., using "AbortController"). **Don't Do This:** Leave long-running API requests running in the background when they are no longer needed. **Why:** Cancellation is a core feature, preventing race conditions and wasted resources. **Example:** """typescript import { createMachine } from 'xstate'; const longRunningTask = (context) => (send) => { const controller = new AbortController(); const signal = controller.signal; fetch('/api/long-task', { signal }) .then((response) => response.json()) .then((data) => { send({ type: 'RESOLVE', data }); }) .catch((error) => { if (error.name === 'AbortError') { console.log('Fetch aborted'); } else { send({ type: 'REJECT', error }); } }); return () => { controller.abort(); // Cancel the fetch request }; }; const machine = createMachine({ id: "cancelableTask", initial: "idle", states: { idle: { on: { START: "loading" } }, loading: { invoke: { id: "myTask", src: longRunningTask, onDone: { target: "success", actions: (context, event) => { console.log("Success", event.data); } }, onError: { target: "failure", actions: (context, event) => { console.log("Failure", event.data); } } }, on: { CANCEL: "idle" // This will cancel the API fetch! } }, success: { type: 'final', }, failure: { type: 'final', } } }); """ ### 2.4. Debouncing and Throttling API Calls **Do This:** Implement debouncing or throttling for API calls that are triggered frequently (e.g., on input change). Use libraries such as "lodash" or "rxjs" to achieve this. **Don't Do This:** Allow API calls to be triggered excessively, leading to performance issues or API rate limits. **Why:** Debouncing and throttling optimize API usage and improve application responsiveness. **Example:** """typescript import { createMachine, assign } from 'xstate'; import { debounce } from 'lodash'; const searchMachine = createMachine({ id: 'search', initial: 'idle', context: { searchTerm: '', searchResults: [], error: null, }, states: { idle: { on: { INPUT: { target: 'debouncing', actions: assign({ searchTerm: (context, event) => event.value }), }, }, }, debouncing: { entry: assign((context) => { // Debounce the API call debouncedSearch(context.searchTerm); return context; }), after: { 300: 'loading', }, on: { INPUT: { target: 'debouncing', actions: assign({ searchTerm: (context, event) => event.value }), }, }, }, loading: { invoke: { id: 'searchAPI', src: (context) => fetch("/api/search?q=${context.searchTerm}").then((response) => response.json() ), onDone: { target: 'success', actions: assign({ searchResults: (context, event) => event.data }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: { type: 'final', }, failure: { on: { INPUT: { target: 'debouncing', actions: assign({ searchTerm: (context, event) => event.value }), }, }, }, }, }); const debouncedSearch = debounce((searchTerm) => { // Send an event *into* the machine so the transition to "loading" // will occur. Find the service and use "send". // Requires a "useService" hook. }, 300); """ ## 3. Data Transformation and Validation ### 3.1. Request Transformation **Do This:** Transform data before sending it to the API. Perform this transformation in actions triggered before the "invoke". **Don't Do This:** Directly pass UI form data to the API without validation or transformation. **Why:** Cleansing and shaping the outgoing data ensures data integrity and conforms to the API's expected format. **Example:** """typescript import { createMachine, assign } from 'xstate'; const submitFormMachine = createMachine({ id: 'submitForm', initial: 'idle', context: { formData: {}, apiData: {}, submissionResult: null, error: null, }, states: { idle: { on: { UPDATE_FORM: { actions: assign({ formData: (context, event) => ({ ...context.formData, [event.name]: event.value }) }), }, SUBMIT: { target: 'transforming', actions: assign({ apiData: (context) => { const { name, email, age } = context.formData; // Transform form data to API format return { full_name: name, email_address: email, age: parseInt(age, 10), }; }, }), }, }, }, transforming: { entry: (context) => { console.log("Transformed data:", context.apiData); }, after: { 100: 'submitting', }, }, submitting: { invoke: { id: 'submitAPI', src: (context) => fetch('/api/submit', { method: 'POST', body: JSON.stringify(context.apiData), }).then((response) => response.json()), onDone: { target: 'success', actions: assign({ submissionResult: (context, event) => event.data }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: { type: 'final', }, failure: { on: { RETRY: 'submitting', }, }, }, }); """ ### 3.2. Response Transformation **Do This:** Transform API data before storing it in the context. **Don't Do This:** Directly use API responses in the UI without transformation. **Why:** Response transformations allow you to decouple your UI from API changes. **Example:** Similar to Request Transformation - you apply transformation to "event.data" in the "assign" action in "onDone". ### 3.3. Validation **Do This:** Validate both request and response data. Libraries like "yup" or "zod" can be used to validate data schemas. Implement custom validation logic where necessary. **Don't Do This:** Assume that API data is always correct or safe. **Why:** Validation protects the data and guarantees that what is being sent to the API or rendered on the UI meets specific constraints. **Example:** """typescript import { createMachine, assign } from 'xstate'; import * as yup from 'yup'; const userSchema = yup.object().shape({ id: yup.number().required(), name: yup.string().required(), email: yup.string().email(), }); const userMachine = createMachine({ id: 'user', initial: 'idle', context: { userData: null, error: null, }, states: { idle: { on: { FETCH: { target: 'loading', }, }, }, loading: { invoke: { id: 'fetchUser', src: () => fetch('/api/user').then((response) => response.json()), onDone: { target: 'success', actions: assign({ userData: (context, event) => { try { return userSchema.validateSync(event.data); } catch (error) { console.error("Validation Error:", error); throw error; // Re-throw validation error to "onError" } }, }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: { type: 'final' }, failure: { on: { RETRY: 'loading', }, }, }, }); """ ## 4. Authentication and Authorization ### 4.1. Token Management **Do This:** Use secure storage mechanisms (e.g., "localStorage" with caution, or cookies with "httpOnly" flag) to store authentication tokens. Context can be used to store if the user is correctly authenticated. **Don't Do This:** Store sensitive tokens in global variables or expose them in client-side code directly. **Why:** Secure token management protects user credentials from unauthorized access. ### 4.2. Interceptors **Do This:** Implement request interceptors to automatically add authentication headers (e.g., Bearer tokens) to API requests. Libraries like "axios" and "ky" provide interceptor mechanisms. **Don't Do This:** Manually add authentication headers to every API request. **Why:** Interceptors centralize authentication logic and prevent code duplication. ### 4.3. Conditional Transitions **Do This:** Implement conditional transitions based on authentication state. For example, redirect unauthenticated users to a login page. Use "guards" in transitions. **Don't Do This:** Allow unauthenticated users to access protected resources. **Why:** Conditional transitions enforce authorization rules and protect sensitive data. **Example:** """typescript import { createMachine, assign } from 'xstate'; const authMachine = createMachine({ id: 'auth', initial: 'checking', context: { isAuthenticated: false, token: null, }, states: { checking: { entry: assign((context) => { // Check for token in local storage const token = localStorage.getItem('access_token'); return { ...context, token }; }), always: [ { target: 'authenticated', cond: (context) => !!context.token }, { target: 'unauthenticated' }, ], }, authenticated: { type: 'final', }, unauthenticated: { on: { LOGIN: { target: 'loggingIn', }, }, }, loggingIn: { invoke: { id: 'loginUser', src: (context, event) => { // Simulate API call return new Promise((resolve, reject) => { setTimeout(() => { if (event.username === 'user' && event.password === 'password') { const token = 'fake_access_token'; localStorage.setItem('access_token', token); resolve({ token }); } else { reject(new Error('Invalid credentials')); } }, 1000); }); }, onDone: { target: 'authenticated', actions: assign((context, event) => ({ isAuthenticated: true, token: event.data.token, })), }, onError: { target: 'unauthenticated', actions: assign({ isAuthenticated: false, token: null, }), }, }, } }, }); """ ## 5. Testing ### 5.1. Unit Tests **Do This:** Write unit tests for all state machines, focusing on transitions, context updates, and API service invocations. Mocks function calls to external source. **Don't Do This:** Deploy state machines without thorough testing. **Why:** Unit tests ensure the correctness and robustness of your state machines. **Example:** """typescript import { interpret } from 'xstate'; import { userMachine } from './userMachine'; // Assuming the machine is in a separate file describe('userMachine', () => { it('should transition from idle to loading on FETCH event', () => { const service = interpret(userMachine); service.start(); service.send({ type: 'FETCH' }); expect(service.state.value).toBe('loading'); }); it('should assign user data on successful fetch', (done) => { const service = interpret(userMachine.withConfig({ services: { fetchUser: () => Promise.resolve({ id: 1, name: 'Test User' }), }, })); service.onTransition((state) => { if (state.value === 'success') { expect(state.context.userData).toEqual({ id: 1, name: 'Test User' }); done(); } }); service.start(); service.send({ type: 'FETCH' }); }); it('should handle errors during fetch', (done) => { const service = interpret(userMachine.withConfig({ services: { fetchUser: () => Promise.reject(new Error('Failed to fetch user')), }, })); service.onTransition((state) => { if (state.value === 'failure') { expect(state.context.error).toBeInstanceOf(Error); done(); } }); service.start(); service.send({ type: 'FETCH' }); }); }); """ ### 5.2. Integration Tests **Do This:** Write integration tests to verify the interaction between the state machine and actual APIs. **Don't Do This:** Rely solely on unit tests when API integration is involved. **Why:** Integration tests ensure that the state machine works correctly with real-world API endpoints. ### 5.3. Mocking **Do This:** Use mocking libraries (e.g., "jest.mock", "nock") to mock API responses during testing. This lets you simulate different scenarios, like success, failure, timeouts, and edge cases. **Don't Do This:** Test against live APIs in all test environments. **Why:** Mocking isolates the state machine from external dependencies, enabling consistent and reliable testing. ## 6. Code Organization ### 6.1. Modular Structure **Do This:** Break large state machines into smaller, reusable modules. Use hierarchical state machines to organize complex logic. **Don't Do This:** Create monolithic state machines that are difficult to understand and maintain. **Why:** Modular code is easier to read, test, and reuse. ### 6.2. Consistent Naming **Do This:** Follow consistent naming conventions for states, events, actions, and guards. **Don't Do This:** Use inconsistent or ambiguous names. **Why:** Consistent naming improves readability and maintainability. ### 6.3. Documentation **Do This:** Document all state machines, including their states, events, actions, guards, and API integrations. **Don't Do This:** Leave state machines undocumented. **Why:** Documentation helps other developers understand and maintain the code. ## 7. Conclusion By adhering to these coding standards, you can create robust, maintainable, and well-tested XState applications that effectively integrate with backend services and external APIs. Remember to adapt these standards to your specific project requirements and development environment.