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,381