# Deployment and DevOps Standards for XState
This document outlines the standards for deploying and managing XState state machines in production environments. It covers build processes, CI/CD pipelines, production considerations, monitoring, and scaling relevant for XState applications. Following these standards will result in more reliable, performant, and maintainable XState-powered systems.
## 1. Build Processes and CI/CD
### 1.1. Automated Builds
**Standard:** Automate the build process using a CI/CD pipeline.
**Do This:**
* Use a CI/CD tool like GitHub Actions, GitLab CI, CircleCI, or Jenkins.
* Define a build pipeline that lints, tests, and packages your XState code.
* Automatically trigger builds on code commits and pull requests.
**Don't Do This:**
* Manually build and deploy code.
* Skip linting and testing in the build process.
**Why:** Automated builds ensure consistent code quality and rapid feedback on code changes.
**Example (GitHub Actions):**
"""yaml
name: XState CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm install
- name: Lint
run: npm run lint # Assumes you have a linting script
- name: Test
run: npm run test # Assumes you have a testing script
- name: Build
run: npm run build # Assumes you have a build script
- name: Deploy
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
# Your deployment script here (e.g., deploying to AWS S3, Netlify, etc.)
echo "Deploying to production..." # replace with actual deploy steps using env vars
env: #replace with actual env vars appropriate for your deployment target
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
"""
### 1.2. Versioning
**Standard:** Employ semantic versioning for your XState machine and related packages.
**Do This:**
* Use semantic versioning (semver) to track changes (e.g., "1.2.3").
* Automate version bumping as part of your CI/CD pipeline using tools like "npm version" or "standard-version".
**Don't Do This:**
* Manually manage versions without a clear strategy.
* Introduce breaking changes without bumping the major version.
**Why:** Semantic versioning helps consumers of your XState machines understand the impact of updates.
**Example:**
"""bash
# Using npm version to bump the patch version
npm version patch -m "chore(release): Bump version to %s"
# Using standard-version to automate versioning based on commit messages
npx standard-version
"""
### 1.3. Artifact Management
**Standard:** Store build artifacts (e.g., compiled JavaScript files, type definitions) in an artifact repository.
**Do This:**
* Use a package registry like npm, or a cloud storage service like AWS S3 or Google Cloud Storage.
* Tag artifacts with the version number.
**Don't Do This:**
* Store artifacts directly in your Git repository.
* Lack versioning for your build artifacts.
**Why:** Artifact management simplifies deployment and rollback processes.
**Example (Publishing to npm):**
"""json
// package.json
{
"name": "@your-org/your-xstate-machine",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"publish": "npm publish --access public"
},
"dependencies": {
"xstate": "^5.3.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
"""
"""bash
npm run build
npm publish --access public
"""
## 2. Production Considerations
### 2.1. State Machine Hydration/Dehydration
**Standard:** Implement strategies for persisting and restoring the XState machine's state in production.
**Do This:**
* Use "JSON.stringify" and "JSON.parse" together with ".withContext()" for simple state persistence.
* Implement custom serialization/deserialization logic for more complex data types in the context.
* Consider using external storage (e.g., databases, Redis) for large or sensitive state data.
**Don't Do This:**
* Store the entire XState machine instance in a database. Just persist the machine's context and current state.
* Expose sensitive data in the serialized state.
**Why:** Persistence ensures stateful applications survive restarts or failures.
**Example:**
"""javascript
import { createMachine } from 'xstate';
const machine = createMachine({
id: 'myMachine',
initial: 'idle',
context: {
count: 0,
user: { id: 1, name: 'John Doe' }
},
states: {
idle: {
on: {
INCREMENT: {
actions: (context) => {
context.count += 1;
}
}
}
}
}
});
// Persist the state
function persistState(state) {
localStorage.setItem('myMachineState', JSON.stringify(state));
}
// Restore the state
function restoreState() {
const storedState = localStorage.getItem('myMachineState');
if (storedState) {
return machine.resolveState(JSON.parse(storedState));
}
return machine.initialState;
}
// Usage
let currentState = restoreState();
console.log(currentState.context);
// Update the state
currentState = machine.transition(currentState, 'INCREMENT');
console.log(currentState.context);
persistState(currentState);
"""
### 2.2. Environment Variables and Configuration
**Standard:** Externalize configuration using environment variables or configuration files.
**Do This:**
* Store sensitive information (e.g., API keys, database passwords) in environment variables.
* Use a library such as "dotenv" in development or utilize platform-specific environment variable configurations (e.g., AWS Lambda environment variables) in production.
* Use configuration files (e.g., JSON, YAML) to define environment-specific settings.
**Don't Do This:**
* Hardcode configuration values directly in your XState machine definitions.
* Commit sensitive information to your repository.
**Why:** Externalized configuration enables flexible deployments across different environments without modifying code.
**Example:**
"""javascript
// Accessing environment variables in an XState machine
import { createMachine } from 'xstate';
const apiEndpoint = process.env.API_ENDPOINT || 'https://default.example.com'; // using a default value
const machine = createMachine({
id: 'apiMachine',
initial: 'idle',
states: {
idle: {
on: {
FETCH: {
target: 'loading',
actions: () => {
console.log("Fetching from ${apiEndpoint}");
// Replace with actual API call using apiEndpoint
}
}
}
},
loading: {}
}
});
// Set API_ENDPOINT environment variable before running
// API_ENDPOINT=https://production.example.com node your-script.js
"""
### 2.3. Error Handling and Logging
**Standard:** Implement robust error handling and logging mechanisms.
**Do This:**
* Use "try...catch" blocks within actions, services, and guards to catch exceptions.
* Implement "onError" handlers to gracefully handle errors within the machine.
* Log important events, transitions, errors, and warnings using a centralized logging system (e.g., Winston, Log4js).
* Include context information in log messages for easier debugging.
**Don't Do This:**
* Ignore errors or allow them to crash the application silently.
* Log sensitive information, such as passwords or API keys.
* Over-log, creating too much noise in the logs.
**Why:** Proper error handling and logging are crucial for identifying and resolving issues in production.
**Example:**
"""javascript
import { createMachine } from 'xstate';
import logger from './logger'; // Hypothetical logger module
import { send } from 'xstate';
const machine = createMachine({
id: 'errorMachine',
initial: 'idle',
states: {
idle: {
on: {
TRIGGER_ERROR: {
target: 'loading',
actions: () => {
logger.info('Triggering intentional error');
}
}
}
},
loading: {
invoke: {
id: 'failingService',
src: () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Intentional service failure'));
}, 1000);
});
},
onDone: { target: 'success' },
onError: {
target: 'failure',
actions: (context, event) => {
logger.error('Service failed', event.data); // Log the error
// Optionally, send a custom event in the failure state
return send({ type: 'CUSTOM_ERROR', error: event.data })
}
}
}
},
success: {
type: 'final',
entry: () => logger.info('Operation successful')
},
failure: {
entry: () => logger.error('Operation failed')
}
}
});
"""
### 2.4. Monitoring and Alerting
**Standard:** Implement monitoring and alerting to track the health and performance of your XState applications.
**Do This:**
* Monitor key metrics, such as state transition frequency, error rates, and service latency.
* Use tools like Prometheus, Grafana, Datadog, or New Relic to collect and visualize metrics.
* Set up alerts to notify you of critical issues, such as high error rates or stalled state machines.
* Monitor machine context size to avoid memory issues.
**Don't Do This:**
* Ignore performance metrics or assume your XState machines are always working correctly.
* Set up alerts for non-critical issues, leading to alert fatigue.
**Why:** Monitoring and alerting enable you to proactively identify and resolve issues before they impact users.
**Example:**
(This example focuses on the *concept* as actual monitoring integration depends heavily on the specific monitoring tool used and the deployment environment)
"""javascript
// Hypothetical example of monitoring state transitions
import { createMachine } from 'xstate';
//import monitoringService from './monitoringService'; // Hypothetical monitoring service
const machine = createMachine({
id: 'monitoringMachine',
initial: 'idle',
states: {
idle: {
on: {
START: {
target: 'running',
actions: () => {
//monitoringService.incrementCounter('state.idle.start');
console.log( 'incrementCounter(state.idle.start)' );
}
}
}
},
running: {
on: {
STOP: "stopped",
ERROR: "errored"
},
entry: () => {
console.log( 'incrementCounter(state.running.entry)' );
//monitoringService.incrementCounter('state.running.entry');
},
exit: () => {
console.log( 'incrementCounter(state.running.exit)' );
//monitoringService.incrementCounter('state.running.exit');
}
},
stopped: {
type: 'final'
},
errored: {
type: 'final'
}
}
});
// Example of using the machine
let currentState = machine.initialState;
currentState = machine.transition(currentState, 'START'); // Increment state.idle.start and state.running.entry
// In a monitoring service (hypothetical):
// function incrementCounter(metricName) {
// // Logic to send metricName to a monitoring tool like Prometheus or Datadog
// }
"""
### 2.5. Feature Flags
**Standard:** Implement feature flags to control the rollout of new features or changes to XState machines.
**Do This:**
* Use a feature flag management system (e.g., LaunchDarkly, Split.io, Unleash).
* Wrap new features or changes to machine logic with feature flags.
* Gradually roll out features to a subset of users before releasing them to everyone.
* Ensure feature flags can be toggled independently of code deployments.
**Don't Do This:**
* Leave feature flags in the codebase indefinitely.
* Create overly complex feature flag logic within the XState machine.
**Why:** Feature flags enable safe and controlled releases, reducing the risk of impacting all users with a buggy or poorly performing feature. They also support A/B testing and experimentation.
**Example:**
"""javascript
import { createMachine } from 'xstate';
//import featureFlags from './featureFlags'; // Hypothetical feature flag module
const machine = createMachine({
id: 'featureFlagMachine',
initial: 'idle',
states: {
idle: {
on: {
START: {
target: 'featureA', // or 'featureB' based on the flag
//guard: () => featureFlags.isFeatureAEnabled() //guard is not needed because it targets based on the result
target: () => {
// Use a function to dynamically determine the target state
return /*featureFlags.isFeatureAEnabled()*/ true? 'featureA' : 'featureB';
}
}
}
},
featureA: {
type: 'final',
entry: () => console.log('Feature A is enabled')
},
featureB: {
type: 'final',
entry: () => console.log("Feature B is enabled")
}
}
});
let currentState = machine.initialState;
currentState = machine.transition(currentState, 'START'); // Navigate based on the feature flag
"""
## 3. Scaling XState Applications
### 3.1. Stateless Design
**Standard:** Design XState machines to be as stateless as possible.
**Do This:**
* Store persistent state externally (e.g., in a database or Redis).
* Minimize the amount of data stored in the machine's context. Only store necessary data there temporarily.
* Favor passing data as events (e.g., "machine.transition(state, { type: 'EVENT', data: { ... } })").
**Don't Do This:**
* Store large or frequently changing data directly in the machine's context.
* Rely on in-memory state to persist data across restarts.
**Why:** Stateless machines are easier to scale horizontally, as any instance can handle any request without relying on local state.
### 3.2. Idempotency
**Standard:** Ensure that state transitions are idempotent, meaning they can be applied multiple times without changing the final result.
**Do This:**
* Design actions and services to be idempotent.
* Avoid side effects that depend on the order of transitions.
**Don't Do This:**
* Perform non-idempotent operations within state transitions.
* Assume that transitions will only be applied once.
**Why:** Idempotency ensures that state transitions are reliable and can be retried without causing unintended consequences.
### 3.3. Distributed State Management
**Standard:** Implement a distributed state management strategy for scaling XState applications across multiple nodes.
**Do This:**
* Use a shared cache (e.g., Redis, Memcached) to store the machine's context.
* Implement a mechanism to synchronize state across nodes (e.g., using pub/sub or a distributed queue).
* Consider using an actor model framework (e.g., Akka, Dapr) for managing distributed state.
**Don't Do This:**
* Rely on local storage or session storage for distributed state management.
* Implement complex, custom synchronization logic.
**Why:** Distributed state management enables you to scale XState applications horizontally without losing state consistency.
### 3.4 Parallel States and Child Machines
**Standard:** Use parallel states and child machines to decompose complex state machines into smaller, more manageable units.
**Do This:**
* Group related states into parallel states to execute them concurrently.
* Encapsulate complex logic into child machines to improve modularity and reusability.
* Use the "invoke" property to spawn child machines.
**Don't Do This:**
* Create monolithic state machines with too many states and transitions.
* Overuse parallel states, leading to increased complexity.
**Why:** Parallel states and child machines improve the maintainability and scalability of XState applications by breaking them down into smaller, more manageable pieces.
**Example (using parallel states):**
"""javascript
import { createMachine, parallel } from 'xstate';
const parallelMachine = createMachine({
id: 'parallelMachine',
type: 'parallel',
states: {
featureA: {
initial: 'idle',
states: {
idle: {
on: { START: 'running' },
},
running: {
on: { STOP: 'idle' },
},
},
},
featureB: {
initial: 'inactive',
states: {
inactive: {
on: { ACTIVATE: 'active' },
},
active: {
on: { DEACTIVATE: 'inactive' },
},
},
},
},
});
"""
This document serves as a foundation for building scalable, reliable, and maintainable XState-powered systems. It is recommended to periodically review and update these standards as the XState ecosystem evolves and new best practices emerge.
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.