Multi-step UIs using Vue.js and finite-state automata

$> whoami

Michał

Pasierbski

Front-end engineer @ EF Education First

Ruby developer

Java deloper

Problem?

Booking flow

Iteration #1

Booking flow

Iteration #?

conditional multi-step form

Text

Text

What is FSM?

  • finite number of states
  • initial state
  • transitions
  • one state at any given time

Example #1: Lightbulb

Example #2: game AI

Example #3: game AI

Other IRL examples:

  • vending machines
  • traffic light
  • elevators

How?

States


export const UNINITIALIZED = 'UNINITIALIZED';
export const TRIP_SELECTION = 'TRIP_SELECTION';
export const EXTRAS_SELECTION = 'EXTRAS_SELECTION';
export const PERSONAL_INFORMATION = 'PERSONAL_INFORMATION';
export const RECAP = 'RECAP';
export const PAYMENT = 'PAYMENT';
export const CONFIRMATION = 'CONFIRMATION';

 Transitions

export const BACK = 'BACK';
export const BOOK_NOW = 'BOOK_NOW';
export const INITIALIZE = 'INITIALIZE';
export const NEXT = 'NEXT';
export const PAY = 'PAY';
export const RESET = 'RESET';
export const SELECT_EXTRAS = 'SELECT_EXTRAS';
export const SELECT_TRIP = 'SELECT_TRIP';

State machine

export default machina.Fsm.extend({
  initialState: UNINITIALIZED,
  states: {}
});
  • initial state
  • states
  • transitions
  • events
export default machina.Fsm.extend({
  initialState: UNINITIALIZED,
  states: {
    [UNINITIALIZED]: {},
    [TRIP_SELECTION]: {},
    [PERSONAL_INFORMATION]: {},
    [RECAP]: {},
    [PAYMENT]: {},
    [CONFIRMATION]: {}
  }
});
export default machina.Fsm.extend({
  initialState: UNINITIALIZED,
  states: {
    [UNINITIALIZED]: {
      [INITIALIZE]() { /* */ }
    },
    [TRIP_SELECTION]: {
      [BOOK_NOW]({ tripId }) {
        this.tripId = tripId;
        this.transition(PERSONAL_INFORMATION);
      }
    },
    [PERSONAL_INFORMATION]: {
      [SET_USER_INFO](data) {
        store.commit(SET_USER_INFO, data);
        this.handle(NEXT);
      },
      [NEXT]: RECAP,
      [BACK]: EXTRAS_SELECTION,
    },
    [RECAP]: {
      [NEXT]: PAYMENT,
      [BACK]: PERSONAL_INFORMATION,
    },
    [PAYMENT]: {
      [BACK]: RECAP,
      [PAY]() {
        this.emit(PROCESSING_PAYMENT);
        this.transition(LOADING);

        return store.dispatch(
          SUBMIT_TRIP_PAYMENT,
          this.tripId
        ).then(() => {
          this.emit(PAYMENT_SUCCEDED);
          this.handle(
            LOADING_SUCCESS,
            CONFIRMATION
          );
        });
      },
      [ACCEPT_PAYMENT]: CONFIRMATION,
    },
    [CONFIRMATION]: {}
  }
});

State components

export default {
  props: {
    trips: { type: Array, required: true },
    done: { type: Function, required: true },
  },
  methods: {
    onSubmit(tripId) {
      return this.done(SELECT_TRIP, { tripId });
    },
    onBookNow(tripId) {
      return this.done(BOOK_NOW, { tripId });
    }
  }
}
  • keep'em dumb
  • use props consistently

Container component

export default {  
  props: {
    fsm: {
      type: Object,
      default: () => (new BookingFSM())
    }
  },
  methods: {
    onDone() {
      const [transition, ...params] = arguments;

      return this.fsm.handle(
        transition || NEXT,
        ...params
      );
    }
  },
  render(h) {
    const vm = this;

    return  h(stateComponents[vm.fsm.state], {
      props: {
        done: vm.onDone,
        trips: vm.trips,
        ...
      }
    });
  }
};
  • KISS
  • data aggregator
  • make FSM injectable

Sample app

Why?

Pros

  • easy to reason about
  • separation of concerns
  • easy to test
  • maintainability
  • dumb components

Cons

  • added complexity layer

Thank you!

@mpasierbski

https://github.com/pasierb

Conditional multi-step UIs

By Michał Pasierbski

Conditional multi-step UIs

  • 483