# Performance Optimization Standards for XState
This document outlines the coding standards for optimizing the performance of XState state machines. These standards aim to improve application speed, responsiveness, and resource usage. Adhering to these guidelines will result in more efficient and scalable state machine implementations.
## 1. General Principles
### 1.1. Minimizing Re-renders
**Do This:** Structure your state machines and React components (or components in your chosen framework) to avoid unnecessary re-renders.
**Don't Do This:** Trigger re-renders on every state transition, without regard for whether the data displayed in the UI has actually changed.
**Why:** Frequent, unnecessary re-renders can severely degrade application performance, especially in complex UIs.
**Example:**
"""jsx
// Anti-pattern: Re-rendering the entire component on every state change
import React from 'react';
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
const lightMachine = createMachine({
id: 'light',
initial: 'green',
states: {
green: { on: { TIMER: 'yellow' } },
yellow: { on: { TIMER: 'red' } },
red: { on: { TIMER: 'green' } }
}
});
const LightComponent = () => {
const [state, send] = useMachine(lightMachine);
// This component will re-render on every TIMER event, even if the light color
// is the only thing that changes
return (
<p>The light is: {state.value}</p>
send('TIMER')}>Next
);
};
export default LightComponent;
"""
"""jsx
// Do This: Optimize component re-renders by only updating when relevant data changes.
import React from 'react';
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
import { useSnapshot } from 'valtio'; // Example state management library
const lightMachine = createMachine({
id: 'light',
initial: 'green',
context: {
color: 'green'
},
states: {
green: {
entry: (context) => { context.color = 'green' },
on: { TIMER: 'yellow' }
},
yellow: {
entry: (context) => { context.color = 'yellow' },
on: { TIMER: 'red' }
},
red: {
entry: (context) => {context.color = 'red'},
on: { TIMER: 'green' }
}
}
});
const LightComponent = () => {
const [state, send] = useMachine(lightMachine);
return (
);
};
// Use React.memo to prevent re-renders if the color prop hasn't changed.
const LightDisplay = React.memo(({ color, send }) => {
console.log("LightDisplay rendered with color: ${color}");
return (
<p>The light is: {color}</p>
send('TIMER')}>Next
);
});
export default LightComponent;
"""
**Explanation:** By using context, the information if the color changed it's easily accessible. And even if the machine transition, only the LightDisplay component re-renders because it gets a new color.
### 1.2. Debouncing and Throttling Event Handling
**Do This:** Use debouncing or throttling techniques for events that are triggered frequently, such as input changes or window resizing.
**Don't Do This:** Directly trigger state transitions on every occurrence of a rapid, repetitive event.
**Why:** Excessive event handling can overload the state machine and lead to performance issues. Debouncing and throttling limit the frequency of event processing, reducing the load.
**Example:**
"""javascript
// Anti-pattern: Directly sending events on every input change
import { createMachine } from 'xstate';
import { useMachine } from '@xstate/react';
const searchMachine = createMachine({
id: 'search',
initial: 'idle',
states: {
idle: {
on: {
INPUT_CHANGE: {
target: 'searching',
actions: (context, event) => {
console.log('Searching for:', event.value);
// Triggers search immediately, which can be inefficient.
}
}
}
},
searching: {} // Removed target since search is immediate.
}
});
function SearchComponent() {
const [state, send] = useMachine(searchMachine);
const handleInputChange = (event) => {
send({ type: 'INPUT_CHANGE', value: event.target.value });
};
return (
);
}
export default SearchComponent;
"""
"""javascript
// Do This: Debounce the INPUT_CHANGE event to avoid excessive search triggers.
import { createMachine } from 'xstate';
import { useMachine } from '@xstate/react';
import { useDebouncedCallback } from 'use-debounce'; // Or any other debounce library
const searchMachine = createMachine({
id: 'search',
initial: 'idle',
context: {
searchTerm: ''
},
states: {
idle: {
on: {
INPUT_CHANGE: {
target: 'searching',
actions: (context, event) => {
context.searchTerm = event.value;
// Will be executed after debounce delay.
}
}
}
},
debouncing: {
after: {
500: {
target: "searching",
}
}
},
searching: {
entry: (context, event) => {
console.log('Searching for debounced:', context.searchTerm);
// Perform the actual search here
},
type: "final",
},
}
});
function SearchComponent() {
const [state, send] = useMachine(searchMachine);
// Debounce the send function to avoid triggering too many INPUT_CHANGE events.
const debouncedSend = useDebouncedCallback(
(value) => {
send({ type: 'INPUT_CHANGE', value });
},
500 // Delay in milliseconds.
);
const handleInputChange = (event) => {
debouncedSend(event.target.value);
};
return (
);
}
export default SearchComponent;
"""
**Explanation:** The second example uses "use-debounce" hook (or another debouncing library). It will wait 500ms after the user stops typing before sending the "INPUT_CHANGE" event to the state machine. This drastically reduces the number of state transitions and search requests.
### 1.3. Selecting Data Carefully
**Do This:** Extract and select only the specific pieces of data needed in your components.
**Don't Do This:** Pass the entire state or context to a component when only a small portion is needed.
**Why:** By being selective about the data passed, you can minimize unnecessary re-renders and improve component performance by preventing updates when irrelevant parts of the state change.
**Example:**
"""jsx
// Anti-pattern: Passing the entire state to a child component.
import React from 'react';
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
const userMachine = createMachine({
id: 'user',
initial: 'idle',
context: {
id: null,
name: '',
email: '',
posts: [],
preferences: {}
},
states: {
idle: {
on: {
LOAD_USER: {
target: 'loading'
}
}
},
loading: {
invoke: {
src: () => Promise.resolve({ id: 1, name: 'John Doe', email: 'john.doe@example.com', posts: [], preferences: {} }),
onDone: {
target: 'loaded',
actions: (context, event) => {
Object.assign(context, event.data);
}
},
onError: 'failed'
}
},
loaded: {}
}
});
const UserComponent = () => {
const [state, send] = useMachine(userMachine);
return (
);
};
const UserInfoDisplay = ({ state }) => {
// Re-renders whenever *any* part of the state changes.
console.log('UserInfoDisplay rendered');
return (
<p>Name: {state.context.name}</p>
<p>Email: {state.context.email}</p>
);
};
export default UserComponent;
"""
"""jsx
// Do This: Select specific parts of the state and pass those to child components.
import React from 'react';
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
const userMachine = createMachine({
id: 'user',
initial: 'idle',
context: {
id: null,
name: '',
email: '',
posts: [],
preferences: {}
},
states: {
idle: {
on: {
LOAD_USER: {
target: 'loading'
}
}
},
loading: {
invoke: {
src: () => Promise.resolve({ id: 1, name: 'John Doe', email: 'john.doe@example.com', posts: [], preferences: {} }),
onDone: {
target: 'loaded',
actions: (context, event) => {
Object.assign(context, event.data);
}
},
onError: 'failed'
}
},
loaded: {}
}
});
const UserComponent = () => {
const [state] = useMachine(userMachine);
const { name, email } = state.context;
return (
);
};
const UserInfoDisplay = React.memo(({ name, email }) => {
// Only re-renders when the name or email props change.
console.log('UserInfoDisplay rendered');
return (
<p>Name: {name}</p>
<p>Email: {email}</p>
);
});
export default UserComponent;
"""
**Explanation:** In the second example, we only extract the "email" and "name" from the context using destructuring assignment. "React.memo" then ensures that "UserInfoDisplay" only re-renders if either of these props change. If other parts of the user data (like posts or preferences) change, "UserInfoDisplay" will *not* needlessly re-render.
## 2. State Machine Optimization
### 2.1. Choosing the Right State Representation
**Do This:** Select a state representation that is appropriate for your application, considering the complexity and performance requirements. Use simple string states when possible, but structured states (objects) might be more effective for intricate logic.
**Don't Do This:** Overuse complex state representations when simple string states would suffice, or vice-versa.
**Why:** The choice of state representation can have a significant impact on performance, especially in complex state machines. Choosing wisely ensures both readability and efficiency.
**Example:**
"""javascript
// Anti-pattern: Overly complex state representation for a simple on/off switch.
import { createMachine } from 'xstate';
const switchMachine = createMachine({
id: 'switch',
initial: { value: 'off', data: null },
states: {
off: {
on: {
TOGGLE: {
target: { value: 'on', data: null }
}
}
},
on: {
on: {
TOGGLE: {
target: { value: 'off', data: null }
}
}
}
}
});
"""
"""javascript
// Do This: Use a simple string state representation for the same switch.
import { createMachine } from 'xstate';
const switchMachine = createMachine({
id: 'switch',
initial: 'off',
states: {
off: {
on: { TOGGLE: 'on' }
},
on: {
on: { TOGGLE: 'off' }
}
}
});
"""
**Explanation:** For basic on/off states, a string representation is more concise, readable, and performant. The first example adds unnecessary complexity and overhead.
### 2.2. Using "guards" Effectively
**Do This:** Use guards to prevent unnecessary state transitions. Guards should be simple, pure functions that quickly evaluate whether a transition should occur.
**Don't Do This:** Perform complex computations or side effects inside guards.
**Why:** Guards are evaluated on every event, so inefficient guards can significantly impact performance. Keep guards lean and focused on simple condition checks.
**Example:**
"""javascript
// Anti-pattern: Performing complex calculations inside a guard.
import { createMachine } from 'xstate';
const complexGuard = (context, event) => {
let result = 0;
for (let i = 0; i < 100000; i++) {
result += Math.random();
}
return result > 50000;
};
const dataMachine = createMachine({
id: 'dataProcessing',
initial: 'idle',
context: {
data: null
},
states: {
idle: {
on: {
PROCESS: {
target: 'processing',
cond: complexGuard // This is slow!
}
}
},
processing: {
type: 'final'
}
}
});
"""
"""javascript
// Do This: Pre-calculate the result and store it in context.
import { createMachine } from 'xstate';
const dataMachine = createMachine({
id: 'dataProcessing',
initial: 'idle',
context: {
calculatedValue: null,
data: null
},
entry: (context) => {
let result = 0;
for (let i = 0; i < 100000; i++) {
result += Math.random();
}
context.calculatedValue = result; // Calculate once and store
},
states: {
idle: {
on: {
PROCESS: {
target: 'processing',
cond: (context) => context.calculatedValue > 50000 // Fast guard
}
}
},
processing: {
type: 'final'
}
}
});
"""
**Explanation:** The second example calculates expensive result only once, storing it in the context. The guard then checks a simple property of the context, vastly improving performance.
### 2.3. Lazy Loading State Machine Definitions
**Do This:** If your application has many state machines or large machine definitions, lazy-load them to reduce the initial load time.
**Don't Do This:** Load all state machine definitions eagerly at application startup.
**Why:** Eager loading can significantly increase startup time, especially for complex applications. Lazy loading fetches state machine definitions only when they become necessary, improving initial responsiveness.
**Example:**
"""javascript
// Anti-pattern: Eagerly importing all state machines
import largeMachine from './machines/largeMachine';
import anotherMachine from './machines/anotherMachine';
const app = () => {
// All machines are loaded at startup, even if they are not immediately needed
console.log("Machines loaded");
return (
...
);
}
"""
"""javascript
// Do This: Lazy-load state machines using dynamic imports.
import React, { useState, useEffect } from 'react';
const App = () => {
const [largeMachine, setLargeMachine] = useState(null);
useEffect(() => {
import('./machines/largeMachine')
.then(module => {
setLargeMachine(module.default);
});
}, []);
if (!largeMachine) {
return Loading...;
}
// Use largeMachine here
return (
Machine loaded
);
}
export default App;
"""
**Explanation:** The second example dynamically imports "largeMachine" using "import()", which returns a promise. This means the machine definition is only fetched when the useEffect hook runs, which may be triggered based on certain conditions. The code also displays a loading message until the machine is loaded.
### 2.4. Optimizing Actions
**Do This:** Keep actions as simple and efficient as possible. Defer complex operations to services or external functions. Batch update the context when possible.
**Don't Do This:** Perform long-running or expensive operations directly within actions.
**Why:** Actions are executed synchronously during state transitions, so poorly implemented actions can block the main thread and make the application unresponsive.
**Example:**
"""javascript
// Anti-pattern: Performing a complex calculation inside an action.
import { createMachine } from 'xstate';
const slowAction = (context, event) => {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += Math.sqrt(i);
}
context.result = result; // This will block the main thread
};
const calculationMachine = createMachine({
id: 'calculation',
initial: 'idle',
context: {
result: null
},
states: {
idle: {
on: {
CALCULATE: {
target: 'calculating',
actions: slowAction // Poor performance
}
}
},
calculating: {
type: 'final'
}
}
});
"""
"""javascript
// Do This: Offload the complex calculation to a service/ promise and update the result asynchronously.
import { createMachine } from 'xstate';
const calculate = () => {
return new Promise((resolve) => {
// wrap blocking for loop in setTimeout to execute asyncronously
setTimeout(() => {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += Math.sqrt(i);
}
resolve(result);
}, 0); // using 0ms timeout to defer execution
})
};
const calculationMachine = createMachine({
id: 'calculation',
initial: 'idle',
context: {
result: null
},
states: {
idle: {
on: {
CALCULATE: {
target: 'calculating',
}
}
},
calculating: {
invoke: {
src: calculate,
onDone: {
target: "idle",
actions: (context, event) => {
context.result = event.data
}
}
}
}
}
});
"""
**Explanation:** The second example offloads the computationally intensive calculation to an asynchronous function using "setTimeout". Then the result it's updated in the context asynchronously. This prevents blocking the main thread and maintains a responsive user interface. Using "invoke" is a more comprehensive approach since services provide clear states on the process of the calculation and make actions more predictable.
## 3. Tooling and Techniques
### 3.1. Using the XState Inspector
**Do This:** Regularly use the XState Inspector to analyze state transitions, event handling, and performance bottlenecks.
**Don't Do This:** Develop and debug state machines without utilizing the Inspector.
**Why:** The XState Inspector provides invaluable insights into the runtime behavior of your state machines, enabling you to identify and resolve performance issues effectively.
"""javascript
// Example: Configuring the XState Inspector in your application
import { inspect } from '@xstate/inspect';
inspect({
iframe: false, // open in new window
url: 'https://statecharts.io/inspect', // or your custom URL
devTools: true // Enable Redux DevTools in the browser
});
// In your machine definition, enable the inspector:
import { createMachine } from 'xstate';
const myMachine = createMachine({
id: 'myMachine',
initial: 'idle',
states: {
idle: {
on: {
DO_SOMETHING: 'doingSomething'
}
},
doingSomething: {}
}
}, {
// Enable the inspector for this machine:
devTools: true,
});
"""
**Explanation:** The code shows how to initialize the XState Inspector and then enable it for each state machine. This will send machine events and state transitions to the Inspector, where you can visualize the state machine's execution and identify potential performance bottlenecks or unexpected behavior. Ensure you follow the updated setup instructions from the official XState documentation for compatibility with the latest versions.
### 3.2. Profiling and Benchmarking
**Do This:** Use browser developer tools and performance profiling tools to measure the performance of your state machines, especially in complex scenarios.
**Don't Do This:** Rely solely on intuition when optimizing performance. Verify your changes with empirical data.
**Why:** Profiling and benchmarking provide concrete evidence of performance improvements or regressions, guiding optimization efforts in the most effective areas.
"""javascript
// Example: Using console.time and console.timeEnd to measure the execution time of a state transition.
import { createMachine } from 'xstate';
const profileMachine = createMachine({
id: 'profile',
initial: 'idle',
states: {
idle: {
on: {
PROCESS: {
target: 'processing',
actions: () => {
console.time('processAction'); // Start timer
// Simulate a complex operation
let result = 0;
for (let i = 0; i < 100000; i++) {
result += Math.sqrt(i);
}
console.timeEnd('processAction'); // End timer
}
}
}
},
processing: {
type: 'final'
}
}
});
"""
**Explanation:** In the "profileMachine" the "console.time" and "console.timeEnd" functions measure the time it takes to execute the slow "processAction". This allows developers to identify potentially slow executions.
### 3.3. Code Splitting and Tree Shaking
**Do This**: Implement code splitting and tree shaking to reduce the initial JavaScript bundle size. This can improve the initial load time of your application.
**Don't Do This**: Include the entire XState library in your initial bundle if you are only using a small subset of its features.
**Why**: Code splitting and tree shaking are optimization techniques that reduce the size of the JavaScript bundles that are sent to the browser. This can improve the initial load time of your application, especially for complex applications with many dependencies.
**Example:**
"""javascript
// Webpack configuration for code splitting
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'all',
},
minimize: true,
minimizer: [new TerserWebpackPlugin()],
},
};
"""
## 4. Security Considerations
### 4.1. Input Validation
**Do This:** Validate all external data that influence state transitions or context updates, especially data coming from user input or external APIs.
**Don't Do This:** Blindly trust external data, as this could lead to unexpected state transitions or vulnerabilities.
**Why:** Input validation prevents malicious or malformed data from corrupting the state machine's behavior or causing security issues. This is a crucial security measure.
**Example:**
"""javascript
// Anti-pattern: Directly using user input without validation.
import { createMachine } from 'xstate';
const userMachine = createMachine({
id: 'user',
initial: 'idle',
context: {
userId: null
},
states: {
idle: {
on: {
SET_ID: {
actions: (context, event) => {
context.userId = event.userId; // Directly using user input
}
}
}
}
}
});
"""
"""javascript
// Do This: Validate the user ID before updating the context.
import { createMachine } from 'xstate';
const userMachine = createMachine({
id: 'user',
initial: 'idle',
context: {
userId: null
},
states: {
idle: {
on: {
SET_ID: {
actions: (context, event) => {
const userId = parseInt(event.userId, 10);
if (userId > 0 && userId < 1000) {
context.userId = userId; // Validated user ID
} else {
// Handle invalid input (e.g., display an error message)
console.error('Invalid user ID');
}
}
}
}
}
}
});
"""
**Explanation:** The second example validates that the received "userId" it's an integer that's in range betwen 0 and 1000. And only then the context is updated. If conditions are not met, an error is returned preventing the context from being initialized with an invalid or malicious value.
By adhering to these coding standards regarding performance optimization, developers can create XState state machines that are not only functional and maintainable but also efficient and scalable, leading to better user experiences and more robust applications.
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.