State Machine & Statecharts Workshop
What is a finite state machine?
idle
pending
resolved
rejected
- Finite number of states
- Initial state
- Finite number of events
- Transitions
- Final states
fetch
resolve
reject
resolved
Coding a FSM
Using switch/case
idle
pending
resolved
rejected
fetch
resolve
reject
resolved
function transition(state, event) {
switch (state) {
case 'idle':
switch (event) {
case 'FETCH':
return 'pending';
default:
return state;
}
case 'pending':
switch (event) {
case 'RESOLVE':
return 'resolved';
case 'REJECT':
return 'rejected';
default:
return state;
}
default:
return state;
}
}
Coding a FSM
Using objects
idle
pending
resolved
rejected
fetch
resolve
reject
resolved
import { Machine } from 'xstate';
const fetchMachine = Machine({
id: 'fetch',
initial: 'idle',
states: {
idle: {},
pending: {},
resolved: {},
rejected: {}
}
});
Configuring the finite states
Coding a FSM
Using objects
idle
pending
resolved
rejected
fetch
resolve
reject
resolved
import { Machine } from 'xstate';
const fetchMachine = Machine({
id: 'fetch',
initial: 'idle',
states: {
idle: {
on: {
FETCH: 'pending'
}
},
pending: {
on: {
RESOLVE: 'resolved',
REJECT: 'rejected'
}
},
resolved: {
type: 'final'
},
rejected: {}
}
});
Configuring the transitions
Transitioning States
idle
pending
resolved
rejected
fetch
resolve
reject
resolved
const nextState = fetchMachine
.transition('idle', 'FETCH');
// Finite state value
nextState.value;
// => 'pending'
// Extended state (context)
nextState.context;
// => undefined
// Actions
nextState.actions;
// => []
Visualization
Interpreting Machines
import { Machine, interpret } from 'xstate';
const fetchMachine = Machine({/* ... */});
const fetchService = interpret(fetchMachine);
// Add state change listener
fetchService.onTransition(state => {
console.log(state);
});
// Start the service
fetchService.start();
// Send events
fetchService.send('FETCH');
fetchService.send({
type: 'RESOLVE',
items: [/* ... */]
});
// Stop the service
fetchService.stop();
Coding a
Real-Life App
Feedback App
- A feedback panel pops up
- "How was your experience?"
- Click Good or Bad
- If Good, go to Thanks panel
- If Bad, go to Form panel
- Form panel asks for more details
- When Form is submitted, go to Thanks panel
- Any panel can be Closed by:
- Clicking the 𝘅 button
- Pressing the ESC key
Creating the Machine
import { Machine, interpret } from 'xstate';
const feedbackMachine = Machine({
id: 'feedback',
initial: 'question',
states: {
question: {},
form: {},
thanks: {},
closed: {}
}
});
Creating the Machine
import { Machine, interpret } from 'xstate';
const feedbackMachine = Machine({
id: 'feedback',
initial: 'question',
states: {
question: {
on: {
GOOD: 'thanks',
BAD: 'form',
CLOSE: 'closed',
ESC: 'closed'
}
},
form: {
on: {
SUBMIT: 'thanks'
CLOSE: 'closed',
ESC: 'closed'
}
},
thanks: {
on: {
CLOSE: 'closed',
ESC: 'closed'
}
},
closed: {}
}
});
const feedbackService = interpret(feedbackMachine)
.onTransition(state => {
console.log(state);
})
.start();
React + XState
npm install xstate --save
npm install @xstate/react --save
Install the XState core library
Install the XState React hook
React + XState
useMachine hook
import React from 'react';
import { useMachine } from '@xstate/react';
// Existing machine
import { feedbackMachine } from './feedbackMachine';
export const App = () => {
// Returns tuple of:
// 1. current state
// 2. send function
const [current, send] =
useMachine(feedbackMachine);
return (
/* ... */
);
};
Machines in Components
import React from 'react';
import { useMachine } from '@xstate/react';
// Existing machine
import { feedbackMachine } from './feedbackMachine';
export const App = () => {
// Returns tuple of:
// 1. current state
// 2. send function
const [current, send] = useMachine(feedbackMachine);
console.log(current);
return (
/* ... */
);
};
Matching States
state.matches(...)
export const App = () => {
const [current, send] = useMachine(feedbackMachine);
return (
<section>
{current.matches('question') ? (
'How was your experience?'
) : current.matches('form') ? (
'Form here'
) : current.matches('acknowledge') ? (
'Thanks for your feedback!'
) : null}
</section>
);
};
Sending Events
send(...)
// Event without payload
<button
onClick={() => send('CLICK_GOOD')}
>
// Event with payload
<form
onSubmit={e => {
// ...
send({
type: 'SUBMIT',
value: // ...
});
}}
>
Adding Actions
onEntry and onExit
// ...
form: {
onEntry: (ctx, e) => {
// code here to focus the input
},
on: {
SUBMIT: 'thanks'
CLOSE: 'closed',
ESC: 'closed'
},
onExit: (ctx, e) => {
// code here to log "exited"
}
},
// ...
Serializing Actions
// ...
form: {
onEntry: 'focusInput',
on: {
SUBMIT: 'thanks'
CLOSE: 'closed',
ESC: 'closed'
},
onExit: 'logExited'
},
// ...
// ...
form: {
onEntry: ['logEntered', 'focusInput'],
on: {
SUBMIT: 'thanks'
CLOSE: 'closed',
ESC: 'closed'
},
onExit: 'logExited'
},
// ...
Declarative Effects
feedbackService.send('BAD');
// State {
// value: 'form',
// context: undefined,
// actions: [
// { type: 'logEntered' },
// { type: 'focusInput' }
// ],
// ...
// }
Configuring Actions
Adding machine options
const feedbackMachine = Machine({
id: 'feedback',
// ...
}, {
actions: {
focusInput: (ctx, e) => {
// code to focus input
}
}
});
Configuring Actions
Extending existing machines
const myMachine = feedbackMachine
.withConfig({
actions: {
focusInput: (ctx, e) => {
// code to focus input
}
}
});
Context
AKA Extended State
const feedbackMachine = Machine({
id: 'feedback',
context: {
response: ''
},
initial: 'question',
states: {
// ...
}
});
feedbackService.initialState
.value; // => 'question'
feedbackService.initialState
.context; // { response: '' }
Setting Context
assign(...)
import { Machine, assign } from 'xstate';
// ...
form: {
on: {
CHANGE: {
// no target!
actions: assign({
response: (ctx, e) => e.value
})
}
}
}
//...
Reading Context
state.context
const nextState = feedbackMachine
.transition('form', {
type: 'CHANGE',
value: 'some response'
});
nextState.context;
// => { response: 'some response' }
Context in UI
<textarea
onChange={e => {
send({
type: 'CHANGE',
value: e.target.value
});
}}
value={current.context.response}
/>
Transition Guards
cond: (ctx, e) => Boolean
// ...
on: {
SUBMIT: [
{
target: 'thanks',
cond: (ctx, e) => ctx.response.length > 0
},
{ target: 'form' }
]
}
//...
Serializing Guards
// ...
on: {
SUBMIT: [
{
target: 'thanks',
cond: 'formValid'
},
{ target: 'form' }
]
}
//...
Configuring Guards
const myMachine = feedbackMachine
.withConfig({
actions: {
// ...
},
guards: {
formValid: (ctx, e) => {
return ctx.response.length > 0;
}
}
});
Nested States
AKA Hierarchical States
// ...
form: {
initial: 'pending',
states: {
pending: {
on: {
SUBMIT: [
{ target: 'submitted', cond: 'formValid' },
{ target: 'invalid' }
]
}
},
invalid: {
on: {
FOCUS: 'pending'
}
},
submitted: {}
},
on: {
CLOSE: 'closed',
ESC: 'closed'
}
},
// ...
Nested States
State Values
const pendingState = feedbackMachine
.transition('question', 'BAD')
.value;
// => { form: 'pending' }
pendingState.matches('form');
// => true
pendingState.matches('form.pending');
// => true
pendingState.matches({ form: 'pending' });
// => true
Final States
// ...
form: {
initial: 'pending',
states: {
pending: {/* ... */},
invalid: {/* ... */},
submitted: {
type: 'final'
}
},
onDone: 'thanks'
}
// ...
Invoked Services
Invoking a Promise
// ...
form: {
initial: 'pending',
states: {/* ... */},
onDone: 'sending'
},
sending: {
invoke: {
src: (ctx, e) => {
// returns a Promise
return sendFeedback(ctx.response);
},
onDone: 'thanks'
}
}
// ...
Invoked Services
Serializing Invoke Sources
// ...
form: {
initial: 'pending',
states: {/* ... */},
onDone: 'sending'
},
sending: {
invoke: {
src: 'sendFeedback',
onDone: 'thanks'
}
}
// ...
Invoked Services
Configuring Invoke Sources
const feedbackMachine = Machine({
id: 'feedback',
// ...
}, {
actions: {/* ... */},
guards: {/* ... */},
services: {
sendFeedback: (ctx, e) => {
return sendFeedback(ctx.response);
}
}
});
const myMachine = feedbackMachine
.withConfig({
actions: {/* ... */},
guards: {/* ... */},
services: {
sendFeedback: (ctx, e) => {
return sendFeedback(ctx.response);
}
}
});
Model-Based Tests
Generating Simple Paths
import { getSimplePaths } from '@xstate/graph';
const simplePaths = getSimplePaths(
feedbackMachine, {
events: {
// Provide some sample events
SUBMIT: [
{
type: 'SUBMIT',
value: 'test feedback input'
}
]
}
});
Model-Based Tests
Simple Paths
{
"\"question\"": {
"state": {
"value": "question"
},
"paths": [[]]
},
"\"thanks\"": {
"state": {
"value": "thanks"
},
"paths": [
[
{
"state": {
"value": "question"
},
"event": {
"type": "CLICK_GOOD"
}
}
],
[
{
"state": {
"value": "question"
},
"event": {
"type": "CLICK_BAD"
}
},
{
"state": {
"value": "form"
},
"event": {
"type": "SUBMIT",
"value": "test feedback input"
}
}
]
]
},
...
}
Model-Based Tests
Using react-testing-library
describe('feedback app', () => {
Object.keys(simplePaths).forEach(key => {
const { paths, state: targetState } = simplePaths[key];
describe(`state: ${key}`, () => {
afterEach(cleanup);
paths.forEach(path => {
const eventString = path.length
? 'via ' + path.map(step => step.event.type).join(', ')
: '';
it(`reaches ${key} ${eventString}`, async () => {
// Render the feedback app
// Add heuristics for asserting that the state is correct
// Add actions that will be executed (and asserted) to produce the events
// Loop through each of the steps, assert the state, assert/execute the action
// Finally, assert that the target state is reached.
});
});
});
});
});
Model-Based Tests
Rendering the app
import Feedback from './Feedback';
import { render, fireEvent, cleanup } from 'react-testing-library';
// ...
// Render the feedback app
const {
getByTestId,
baseElement,
queryByTestId
} = render(<Feedback />);
// ...
Model-Based Tests
Asserting State
// ...
import { Machine, matchesState } from 'xstate';
// ...
// Add heuristics for asserting that the state is correct
function assertState(state) {
if (state.matches('question')) {
// assert that the question screen is visible
assert.ok(getByTestId('question-screen'));
} else if (state.matches('form')) {
// assert that the form screen is visible
assert.ok(getByTestId('form-screen'));
} else if (state.matches('acknowledge')) {
// assert that the acknowledge screen is visible
assert.ok(getByTestId('acknowledge-screen'));
} else if (state.matches('closed')) {
// assert that the acknowledge screen is hidden
assert.isNull(queryByTestId('acknowledge-screen'));
}
}
// ...
Model-Based Tests
Executing Actions
// Add actions that will be executed (and asserted) to produce the events
function assertAction(event) {
const action = {
CLICK_GOOD: () => {
fireEvent.click(getByTestId('good-button'));
},
CLICK_BAD: () => {
fireEvent.click(getByTestId('bad-button'));
},
SUBMIT: e => {
fireEvent.change(getByTestId('response-input'), {
target: { value: e.value }
});
fireEvent.click(getByTestId('submit-button'));
},
CLOSE: () => {
fireEvent.click(getByTestId('close-button'));
},
ESC: () => {
fireEvent.keyDown(baseElement, { key: 'Escape' });
}
}[event.type];
if (action) {
// Execute the action
action(event);
} else {
throw new Error(`Action for event '${event.type}' not found`);
}
}
Model-Based Tests
Testing Each Path
// Loop through each of the steps,
// assert the state,
// assert/execute the action
for (let step of path) {
const { state, event } = step;
await assertState(state);
await assertAction(event);
}
// Finally, assert that the
// target state is reached.
await assertState(targetState);
Model-Based Tests
Running the Tests
PASS src/App.test.js
feedback app
state: "question"
✓ reaches "question" (9ms)
state: "thanks"
✓ reaches "thanks" via CLICK_GOOD (7ms)
✓ reaches "thanks" via CLICK_BAD, SUBMIT (9ms)
state: "closed"
✓ reaches "closed" via CLICK_GOOD, CLOSE (6ms)
✓ reaches "closed" via CLICK_GOOD, ESC (7ms)
✓ reaches "closed" via CLICK_BAD, SUBMIT, CLOSE (10ms)
✓ reaches "closed" via CLICK_BAD, SUBMIT, ESC (13ms)
✓ reaches "closed" via CLICK_BAD, CLOSE (7ms)
✓ reaches "closed" via CLICK_BAD, ESC (5ms)
✓ reaches "closed" via CLOSE (3ms)
✓ reaches "closed" via ESC (3ms)
state: "form"
✓ reaches "form" via CLICK_BAD (5ms)
Test Suites: 1 passed, 1 total
Tests: 12 passed, 12 total
Snapshots: 0 total
Time: 0.41s, estimated 1s
Storybook
Writing stories
Congratulations!
Workshop Complete 🎉
React Finland Statecharts Workshop
By David Khourshid
React Finland Statecharts Workshop
- 5,377