Graph Utilities
State machines can be represented as directed graphs, where states are nodes and transitions are edges. XState provides utilities to traverse these graphs and generate paths: sequences of events that transition a machine from one state to another.
Why use path generation?​
Path generation is useful for:
- Model-based testing - automatically generate test cases that cover all reachable states and transitions
- Visualization - understand the structure and flow of complex machines
- Validation - verify all states are reachable and all transitions are exercised
- Documentation - generate human-readable sequences of user flows
Quick start​
import { createMachine } from 'xstate';
import { getShortestPaths, getSimplePaths } from 'xstate/graph';
const machine = createMachine({
initial: 'a',
states: {
a: {
on: { NEXT: 'b', SKIP: 'c' }
},
b: {
on: { NEXT: 'c' }
},
c: { type: 'final' }
}
});
const shortestPaths = getShortestPaths(machine);
// - a
// - a -> b
// - a -> c (via SKIP, not through b)
const simplePaths = getSimplePaths(machine);
// - a
// - a -> b
// - a -> b -> c
// - a -> c (via SKIP)
Core concepts​
Paths and steps​
A path represents a sequence of transitions from one state to another. Each path contains:
state- the final state reached by this pathsteps- array of steps taken to reach that state
A step represents a single transition:
state- the state before this transitionevent- the event that triggered the transition
// Example path structure
{
// The final state reached by this path
state: { value: 'thanks', context: {} },
// The steps taken to reach this state
steps: [
{ state: { value: 'question' }, event: { type: 'CLICK_BAD' } },
{ state: { value: 'form' }, event: { type: 'SUBMIT' } }
]
}
Shortest vs simple paths​
Shortest paths use Dijkstra's algorithm to find the minimum number of transitions to reach each state. Use shortest paths when you want:
- One efficient path to each state
- Minimal test cases for state coverage
- Quick traversal verification
Simple paths use depth-first search to find all possible non-cyclic paths. Use simple paths when you want:
- Complete transition coverage
- All possible user flows
- Exhaustive testing
getShortestPaths(logic, options?)​
Returns the shortest path from the initial state to every reachable state.
import { createMachine } from 'xstate';
import { getShortestPaths } from 'xstate/graph';
const feedbackMachine = createMachine({
id: 'feedback',
initial: 'question',
states: {
question: {
on: {
CLICK_GOOD: { target: 'thanks' },
CLICK_BAD: { target: 'form' },
CLOSE: { target: 'closed' }
}
},
form: {
on: {
SUBMIT: { target: 'thanks' },
CLOSE: { target: 'closed' }
}
},
thanks: {
on: {
CLOSE: { target: 'closed' }
}
},
closed: {
type: 'final'
}
}
});
const paths = getShortestPaths(feedbackMachine);
// Returns array of paths:
// [
// { state: 'question', steps: [] },
// { state: 'thanks', steps: [{ state: 'question', event: { type: 'CLICK_GOOD' } }] },
// { state: 'form', steps: [{ state: 'question', event: { type: 'CLICK_BAD' } }] },
// { state: 'closed', steps: [{ state: 'question', event: { type: 'CLOSE' } }] }
// ]
Notice that reaching closed from thanks (2 steps) is not included because there's a shorter path directly from question (1 step).
getSimplePaths(logic, options?)​
Returns all simple (non-cyclic) paths from the initial state to every reachable state.
import { getSimplePaths } from 'xstate/graph';
const paths = getSimplePaths(feedbackMachine);
// Returns many more paths, including:
// - question → thanks (via CLICK_GOOD)
// - question → form → thanks (via CLICK_BAD, SUBMIT)
// - question → thanks → closed (via CLICK_GOOD, CLOSE)
// - question → form → thanks → closed (via CLICK_BAD, SUBMIT, CLOSE)
// - question → form → closed (via CLICK_BAD, CLOSE)
// - question → closed (via CLOSE)
// ... and more
Simple paths provide complete transition coverage - every valid sequence through the machine.
getPathsFromEvents(logic, events, options?)​
Traces a specific sequence of events and returns the resulting path. Useful for validating that a specific user flow works as expected.
import { getPathsFromEvents } from 'xstate/graph';
const path = getPathsFromEvents(feedbackMachine, [
{ type: 'CLICK_BAD' },
{ type: 'SUBMIT' },
{ type: 'CLOSE' }
]);
// Returns:
// {
// state: { value: 'closed' },
// ,
// steps: [
// { state: { value: 'question' }, event: { type: 'CLICK_BAD' } },
// { state: { value: 'form' }, event: { type: 'SUBMIT' } },
// { state: { value: 'thanks' }, event: { type: 'CLOSE' } }
// ]
// }
Traversal options​
All path functions accept an options object to customize traversal:
events​
Specify event payloads for events that require data. By default, events are traversed with just their type.
import { setup, assign } from 'xstate';
import { getShortestPaths } from 'xstate/graph';
const counterMachine = setup({
types: {
events: {} as { type: 'INC'; value: number }
}
}).createMachine({
id: 'counter',
initial: 'active',
context: { count: 0 },
states: {
active: {
on: {
INC: {
actions: assign({
count: ({ context, event }) => context.count + event.value
})
}
}
}
}
});
const paths = getShortestPaths(counterMachine, {
events: [
{ type: 'INC', value: 1 },
{ type: 'INC', value: 5 },
{ type: 'INC', value: 10 }
]
});
You can also provide a function that returns events based on the current state:
const paths = getShortestPaths(counterMachine, {
events: (state) => {
// Generate different events based on context
if (state.context.count < 10) {
return [{ type: 'INC', value: 1 }];
}
return [{ type: 'INC', value: 10 }];
}
});
toState​
Filter paths to only those reaching states matching a condition:
const paths = getShortestPaths(feedbackMachine, {
toState: (state) => state.value === 'closed'
});
// Only returns paths ending in 'closed' state
fromState​
Start traversal from a specific state instead of the initial state:
import { createActor } from 'xstate';
const actor = createActor(feedbackMachine).start();
actor.send({ type: 'CLICK_BAD' });
const paths = getShortestPaths(feedbackMachine, {
fromState: actor.getSnapshot()
});
// Paths starting from 'form' state
stopWhen​
Stop traversing when a condition is met:
const paths = getShortestPaths(counterMachine, {
events: [{ type: 'INC', value: 1 }],
stopWhen: (state) => state.context.count >= 5
});
// Stops exploring paths once count reaches 5
limit​
Maximum number of states to traverse (prevents infinite loops with context):
const paths = getShortestPaths(counterMachine, {
events: [{ type: 'INC', value: 1 }],
limit: 100 // Stop after 100 unique states
});
serializeState and serializeEvent​
Customize how states and events are serialized for comparison. By default, states are serialized as JSON strings of their value and context.
const paths = getShortestPaths(machine, {
serializeState: (state) => {
// Only consider state value, ignore context
return JSON.stringify(state.value);
},
serializeEvent: (event) => {
// Custom event serialization
return event.type;
}
});
Working with context​
When machines have dynamic context, the state space can become infinite. Use stopWhen or limit to bound the traversal:
import { setup, assign } from 'xstate';
import { getShortestPaths } from 'xstate/graph';
const counterMachine = setup({
types: {
events: {} as { type: 'INC'; value: number } | { type: 'DEC'; value: number }
}
}).createMachine({
id: 'counter',
initial: 'counting',
context: { count: 0 },
states: {
counting: {
always: {
target: 'done',
guard: ({ context }) => context.count >= 10
},
on: {
INC: {
actions: assign({
count: ({ context, event }) => context.count + event.value
})
},
DEC: {
actions: assign({
count: ({ context, event }) => context.count - event.value
})
}
}
},
done: {
type: 'final'
}
}
});
const paths = getShortestPaths(counterMachine, {
events: [
{ type: 'INC', value: 1 },
{ type: 'INC', value: 5 },
{ type: 'DEC', value: 1 }
],
// Bound the state space
stopWhen: (state) => state.context.count > 15 || state.context.count < -5
});
getAdjacencyMap(logic, options?)​
Returns a map representing the state machine as a graph, with states as keys and their transitions as values.
import { getAdjacencyMap } from 'xstate/graph';
const adjacencyMap = getAdjacencyMap(feedbackMachine);
// Structure:
// {
// '"question"': {
// state: { value: 'question', ... },
// transitions: {
// '{"type":"CLICK_GOOD"}': { event: {...}, state: {...} },
// '{"type":"CLICK_BAD"}': { event: {...}, state: {...} },
// '{"type":"CLOSE"}': { event: {...}, state: {...} }
// }
// },
// '"form"': { ... },
// ...
// }
toDirectedGraph(machine)​
Converts a machine to a directed graph structure for visualization:
import { toDirectedGraph } from 'xstate/graph';
const digraph = toDirectedGraph(feedbackMachine);
// Structure:
// {
// id: 'feedback',
// stateNode: StateNode,
// children: [
// { id: 'feedback.question', children: [], edges: [...] },
// { id: 'feedback.form', children: [], edges: [...] },
// ...
// ],
// edges: [
// { source: StateNode, target: StateNode, transition: {...} },
// ...
// ]
// }
Model-based testing​
Path generation enables model-based testing - generating test cases directly from your state machine. Use createTestModel to wrap your machine with testing utilities:
import { createMachine } from 'xstate';
import { createTestModel } from 'xstate/graph';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' }
},
active: {
on: { TOGGLE: 'inactive' }
}
}
});
const model = createTestModel(toggleMachine);
// Get paths for testing
const paths = model.getShortestPaths();
// Use with your test framework
describe('toggle', () => {
for (const path of paths) {
it(`reaches ${JSON.stringify(path.state.value)}`, async () => {
await path.test({
events: {
TOGGLE: async () => {
// Execute the toggle action in your app
await page.click('#toggle-button');
}
},
states: {
inactive: async (state) => {
// Assert the app is in inactive state
await expect(page.locator('#status')).toHaveText('Inactive');
},
active: async (state) => {
await expect(page.locator('#status')).toHaveText('Active');
}
}
});
});
}
});
TestModel methods​
model.getShortestPaths(options?)- get shortest pathsmodel.getSimplePaths(options?)- get all simple pathsmodel.getPaths(pathGenerator)- use custom path generator
Path testing​
Each path returned by TestModel has a test method that:
- Starts from the initial state
- Executes each event in the path using your event handlers
- Verifies each state using your state assertions
path.test({
events: {
// Map event types to async functions that execute the event
CLICK_GOOD: async () => await page.click('.good-button'),
SUBMIT: async () => await page.click('button[type="submit"]')
},
states: {
// Map state values to async assertions
question: async () => await expect(page.locator('.question')).toBeVisible(),
form: async () => await expect(page.locator('form')).toBeVisible(),
thanks: async () => await expect(page.locator('.thanks')).toBeVisible()
}
});
Path deduplication​
When using simple paths, you may get many paths where shorter paths are prefixes of longer ones. The deduplicatePaths utility removes redundant paths:
import { getSimplePaths, deduplicatePaths } from 'xstate/graph';
const allPaths = getSimplePaths(machine);
const uniquePaths = deduplicatePaths(allPaths);
// Removes paths that are prefixes of longer paths
// e.g., [A→B] is removed if [A→B→C] exists
Example: Complete test generation​
import { createMachine } from 'xstate';
import { createTestModel } from 'xstate/graph';
import { test, expect } from 'vitest';
const authMachine = createMachine({
id: 'auth',
initial: 'loggedOut',
states: {
loggedOut: {
on: {
LOGIN: 'loggingIn'
}
},
loggingIn: {
on: {
SUCCESS: 'loggedIn',
FAILURE: 'loggedOut'
}
},
loggedIn: {
on: {
LOGOUT: 'loggedOut'
}
}
}
});
const model = createTestModel(authMachine);
describe('auth flows', () => {
const paths = model.getShortestPaths({
toState: (state) => state.matches('loggedIn')
});
for (const path of paths) {
test(path.description, async () => {
// Setup
const app = await setupApp();
await path.test({
events: {
LOGIN: async () => {
await app.fillLoginForm('user', 'pass');
await app.submit();
},
SUCCESS: async () => {
await app.mockAuthSuccess();
},
LOGOUT: async () => {
await app.clickLogout();
}
},
states: {
loggedOut: async () => {
expect(app.isLoggedIn()).toBe(false);
},
loggingIn: async () => {
expect(app.isLoading()).toBe(true);
},
loggedIn: async () => {
expect(app.isLoggedIn()).toBe(true);
}
}
});
});
}
});