Graph & Paths
Generate paths through state machines for testing and analysis
The graph utilities are now included in the main xstate package. Import from xstate/graph instead of the deprecated @xstate/graph package.
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 moreSimple 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' statefromState
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' statestopWhen
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 5limit
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()
}
});You can generate test paths from your state machines in Stately Studio, with support for Playwright, Vitest, and custom formats.
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] existsExample: 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);
}
}
});
});
}
});