State machines make complex UI logic predictable. Here's how to use XState effectively.
Why State Machines#
1// Traditional approach: scattered boolean flags
2const [isLoading, setIsLoading] = useState(false);
3const [isError, setIsError] = useState(false);
4const [isSuccess, setIsSuccess] = useState(false);
5const [data, setData] = useState(null);
6
7// Problems:
8// - Impossible states (isLoading && isError)
9// - Easy to forget resetting flags
10// - Hard to track all transitions
11
12// State machine approach: explicit states
13type State = 'idle' | 'loading' | 'success' | 'error';
14// Only one state at a time, transitions are explicitBasic State Machine#
1import { createMachine, assign } from 'xstate';
2import { useMachine } from '@xstate/react';
3
4// Define the machine
5const fetchMachine = createMachine({
6 id: 'fetch',
7 initial: 'idle',
8 context: {
9 data: null,
10 error: null,
11 },
12 states: {
13 idle: {
14 on: {
15 FETCH: 'loading',
16 },
17 },
18 loading: {
19 invoke: {
20 src: 'fetchData',
21 onDone: {
22 target: 'success',
23 actions: assign({
24 data: (_, event) => event.data,
25 }),
26 },
27 onError: {
28 target: 'error',
29 actions: assign({
30 error: (_, event) => event.data,
31 }),
32 },
33 },
34 },
35 success: {
36 on: {
37 REFRESH: 'loading',
38 RESET: 'idle',
39 },
40 },
41 error: {
42 on: {
43 RETRY: 'loading',
44 RESET: 'idle',
45 },
46 },
47 },
48});
49
50// Use in component
51function DataFetcher({ url }: { url: string }) {
52 const [state, send] = useMachine(fetchMachine, {
53 services: {
54 fetchData: async () => {
55 const response = await fetch(url);
56 if (!response.ok) throw new Error('Fetch failed');
57 return response.json();
58 },
59 },
60 });
61
62 return (
63 <div>
64 {state.matches('idle') && (
65 <button onClick={() => send('FETCH')}>Load Data</button>
66 )}
67
68 {state.matches('loading') && <Spinner />}
69
70 {state.matches('success') && (
71 <div>
72 <pre>{JSON.stringify(state.context.data, null, 2)}</pre>
73 <button onClick={() => send('REFRESH')}>Refresh</button>
74 </div>
75 )}
76
77 {state.matches('error') && (
78 <div>
79 <p>Error: {state.context.error.message}</p>
80 <button onClick={() => send('RETRY')}>Retry</button>
81 </div>
82 )}
83 </div>
84 );
85}Form State Machine#
1interface FormContext {
2 values: Record<string, string>;
3 errors: Record<string, string>;
4 touched: Record<string, boolean>;
5}
6
7const formMachine = createMachine<FormContext>({
8 id: 'form',
9 initial: 'editing',
10 context: {
11 values: {},
12 errors: {},
13 touched: {},
14 },
15 states: {
16 editing: {
17 on: {
18 CHANGE: {
19 actions: assign({
20 values: (ctx, event) => ({
21 ...ctx.values,
22 [event.field]: event.value,
23 }),
24 }),
25 },
26 BLUR: {
27 actions: [
28 assign({
29 touched: (ctx, event) => ({
30 ...ctx.touched,
31 [event.field]: true,
32 }),
33 }),
34 'validateField',
35 ],
36 },
37 SUBMIT: [
38 {
39 target: 'submitting',
40 cond: 'isValid',
41 },
42 {
43 actions: 'showErrors',
44 },
45 ],
46 },
47 },
48 submitting: {
49 invoke: {
50 src: 'submitForm',
51 onDone: 'success',
52 onError: {
53 target: 'editing',
54 actions: assign({
55 errors: (_, event) => event.data.errors,
56 }),
57 },
58 },
59 },
60 success: {
61 type: 'final',
62 },
63 },
64}, {
65 guards: {
66 isValid: (ctx) => Object.keys(ctx.errors).length === 0,
67 },
68 actions: {
69 validateField: assign({
70 errors: (ctx, event) => {
71 const errors = { ...ctx.errors };
72 const value = ctx.values[event.field];
73
74 if (!value) {
75 errors[event.field] = 'Required';
76 } else {
77 delete errors[event.field];
78 }
79
80 return errors;
81 },
82 }),
83 showErrors: assign({
84 touched: (ctx) => {
85 const touched: Record<string, boolean> = {};
86 Object.keys(ctx.values).forEach(key => {
87 touched[key] = true;
88 });
89 return touched;
90 },
91 }),
92 },
93});
94
95// Usage
96function ContactForm() {
97 const [state, send] = useMachine(formMachine, {
98 services: {
99 submitForm: async (ctx) => {
100 const response = await fetch('/api/contact', {
101 method: 'POST',
102 body: JSON.stringify(ctx.values),
103 });
104 if (!response.ok) {
105 const data = await response.json();
106 throw { errors: data.errors };
107 }
108 },
109 },
110 });
111
112 const { values, errors, touched } = state.context;
113
114 if (state.matches('success')) {
115 return <p>Thank you for your message!</p>;
116 }
117
118 return (
119 <form onSubmit={(e) => { e.preventDefault(); send('SUBMIT'); }}>
120 <input
121 value={values.email || ''}
122 onChange={(e) => send({ type: 'CHANGE', field: 'email', value: e.target.value })}
123 onBlur={() => send({ type: 'BLUR', field: 'email' })}
124 />
125 {touched.email && errors.email && <span>{errors.email}</span>}
126
127 <button disabled={state.matches('submitting')}>
128 {state.matches('submitting') ? 'Sending...' : 'Send'}
129 </button>
130 </form>
131 );
132}Authentication Machine#
1interface AuthContext {
2 user: User | null;
3 error: string | null;
4}
5
6type AuthEvent =
7 | { type: 'LOGIN'; email: string; password: string }
8 | { type: 'LOGOUT' }
9 | { type: 'REFRESH' };
10
11const authMachine = createMachine<AuthContext, AuthEvent>({
12 id: 'auth',
13 initial: 'checking',
14 context: {
15 user: null,
16 error: null,
17 },
18 states: {
19 checking: {
20 invoke: {
21 src: 'checkAuth',
22 onDone: {
23 target: 'authenticated',
24 actions: assign({ user: (_, e) => e.data }),
25 },
26 onError: 'unauthenticated',
27 },
28 },
29 unauthenticated: {
30 on: {
31 LOGIN: 'authenticating',
32 },
33 },
34 authenticating: {
35 invoke: {
36 src: 'login',
37 onDone: {
38 target: 'authenticated',
39 actions: assign({
40 user: (_, e) => e.data,
41 error: null,
42 }),
43 },
44 onError: {
45 target: 'unauthenticated',
46 actions: assign({
47 error: (_, e) => e.data.message,
48 }),
49 },
50 },
51 },
52 authenticated: {
53 on: {
54 LOGOUT: 'loggingOut',
55 REFRESH: 'refreshing',
56 },
57 },
58 refreshing: {
59 invoke: {
60 src: 'refreshToken',
61 onDone: {
62 target: 'authenticated',
63 actions: assign({ user: (_, e) => e.data }),
64 },
65 onError: 'unauthenticated',
66 },
67 },
68 loggingOut: {
69 invoke: {
70 src: 'logout',
71 onDone: {
72 target: 'unauthenticated',
73 actions: assign({ user: null }),
74 },
75 onError: 'authenticated',
76 },
77 },
78 },
79});
80
81// Provider
82const AuthContext = createContext<{
83 state: any;
84 send: any;
85 user: User | null;
86} | null>(null);
87
88function AuthProvider({ children }: { children: React.ReactNode }) {
89 const [state, send] = useMachine(authMachine, {
90 services: {
91 checkAuth: () => authService.getCurrentUser(),
92 login: (_, event) => authService.login(event.email, event.password),
93 logout: () => authService.logout(),
94 refreshToken: () => authService.refresh(),
95 },
96 });
97
98 return (
99 <AuthContext.Provider value={{ state, send, user: state.context.user }}>
100 {children}
101 </AuthContext.Provider>
102 );
103}Wizard/Multi-Step Form#
1const wizardMachine = createMachine({
2 id: 'wizard',
3 initial: 'step1',
4 context: {
5 step1Data: {},
6 step2Data: {},
7 step3Data: {},
8 },
9 states: {
10 step1: {
11 on: {
12 NEXT: {
13 target: 'step2',
14 actions: assign({
15 step1Data: (_, e) => e.data,
16 }),
17 },
18 },
19 },
20 step2: {
21 on: {
22 NEXT: {
23 target: 'step3',
24 actions: assign({
25 step2Data: (_, e) => e.data,
26 }),
27 },
28 BACK: 'step1',
29 },
30 },
31 step3: {
32 on: {
33 SUBMIT: 'submitting',
34 BACK: 'step2',
35 },
36 },
37 submitting: {
38 invoke: {
39 src: 'submitWizard',
40 onDone: 'complete',
41 onError: 'step3',
42 },
43 },
44 complete: {
45 type: 'final',
46 },
47 },
48});
49
50function Wizard() {
51 const [state, send] = useMachine(wizardMachine);
52
53 const steps = {
54 step1: <Step1 onNext={(data) => send({ type: 'NEXT', data })} />,
55 step2: (
56 <Step2
57 onNext={(data) => send({ type: 'NEXT', data })}
58 onBack={() => send('BACK')}
59 />
60 ),
61 step3: (
62 <Step3
63 onSubmit={() => send('SUBMIT')}
64 onBack={() => send('BACK')}
65 />
66 ),
67 submitting: <Spinner />,
68 complete: <Success />,
69 };
70
71 const currentStep = Object.keys(steps).find(step =>
72 state.matches(step)
73 ) as keyof typeof steps;
74
75 return (
76 <div>
77 <ProgressBar current={currentStep} />
78 {steps[currentStep]}
79 </div>
80 );
81}Parallel States#
1const playerMachine = createMachine({
2 id: 'player',
3 type: 'parallel',
4 states: {
5 playback: {
6 initial: 'paused',
7 states: {
8 paused: {
9 on: { PLAY: 'playing' },
10 },
11 playing: {
12 on: { PAUSE: 'paused' },
13 },
14 },
15 },
16 volume: {
17 initial: 'normal',
18 states: {
19 muted: {
20 on: { UNMUTE: 'normal' },
21 },
22 normal: {
23 on: { MUTE: 'muted' },
24 },
25 },
26 },
27 fullscreen: {
28 initial: 'off',
29 states: {
30 off: {
31 on: { ENTER_FULLSCREEN: 'on' },
32 },
33 on: {
34 on: { EXIT_FULLSCREEN: 'off' },
35 },
36 },
37 },
38 },
39});
40
41// Check multiple states
42state.matches({ playback: 'playing', volume: 'muted' });Testing State Machines#
1import { interpret } from 'xstate';
2
3describe('fetchMachine', () => {
4 it('should transition to loading on FETCH', () => {
5 const service = interpret(fetchMachine).start();
6
7 service.send('FETCH');
8
9 expect(service.state.matches('loading')).toBe(true);
10 });
11
12 it('should handle successful fetch', async () => {
13 const mockData = { id: 1, name: 'Test' };
14
15 const service = interpret(
16 fetchMachine.withConfig({
17 services: {
18 fetchData: async () => mockData,
19 },
20 })
21 ).start();
22
23 service.send('FETCH');
24
25 await waitFor(() => {
26 expect(service.state.matches('success')).toBe(true);
27 expect(service.state.context.data).toEqual(mockData);
28 });
29 });
30
31 it('should handle fetch error', async () => {
32 const service = interpret(
33 fetchMachine.withConfig({
34 services: {
35 fetchData: async () => {
36 throw new Error('Network error');
37 },
38 },
39 })
40 ).start();
41
42 service.send('FETCH');
43
44 await waitFor(() => {
45 expect(service.state.matches('error')).toBe(true);
46 });
47 });
48});Best Practices#
Design:
✓ Model states explicitly
✓ Use context for data
✓ Define all transitions
✓ Handle all edge cases
Organization:
✓ One machine per feature
✓ Extract services and guards
✓ Use TypeScript for events
✓ Document state charts
Testing:
✓ Test all state transitions
✓ Test guards and actions
✓ Test service invocations
✓ Use model-based testing
Conclusion#
State machines eliminate impossible states and make complex UI logic explicit. XState provides powerful tools for modeling workflows, forms, and async operations. Start simple and add complexity as needed. The upfront modeling investment pays off in reliability and maintainability.