Back to Blog
ReactXStateState MachinesState Management

State Machines in React with XState

Build predictable UIs with state machines. From basic concepts to complex workflows to testing patterns.

B
Bootspring Team
Engineering
October 3, 2021
7 min read

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 explicit

Basic 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.

Share this article

Help spread the word about Bootspring