Home

Awesome

Motivation

User interfaces are reactive systems which can be modelized accurately by state machines. There is a number of state machine libraries in the field with varying design objectives. We have proposed an extended state machine library with a minimal API, architected around a single causal, effect-less function. This particular design requires integration with the interfaced systems, in order to produce the necessary effects (user events, system events, user actions). We present here an integration of our proposed machine library with Vue.

This document is structured as follows :

Modelling user interfaces with state machines

We are going all along to refer to a image search application example to illustrate our argumentation. Cf. Example section for more details.

In a traditional architecture, a simple scenario would be expressed as follows :

image search basic scenario

What we can derive from that is that the application is interfacing with other systems : the user interface and what we call external systems (local storage, databases, etc.). The application responsibility is to translate user actions on the user interface into commands on the external systems, execute those commands and deal with their result.

In our proposed architecture, the same scenario would become :

image search basic scenario

In that architecture, the application is refactored into a mediator, a preprocessor, a state machine, a command handler, and an effect handler. The application is thus split into smaller parts which address specific concerns :

While the architecture may appear more complex (isolating concerns means more parts), we have reduced the complexity born from the interconnection between the parts.

Concretely, we increased the testability of our implementation :

We also have achieved greater modularity: our parts are coupled only through their interface. For instance, we use in our example below Rxjs for preprocessing events, and state-transducer as state machine library. We could easily switch to most and xstate if the need be, or to a barebone event emitter (like emitonoff) by simply building interface adapters.

There are more benefits but this is not the place to go about them. Cf:

Installation

npm install

Code examples

For the impatient ones, you can directly review the available demos:

Code playgroundMachineScreenshot
password metergraphpassword meter demo
flickr image searchimage search interface

API design goals

We want to have an integration which is generic enough to accommodate a large set of use cases, and specific enough to be able to take advantage as much as possible of the Vue ecosystem and API. Unit-testing should ideally be based on the specifications of the behaviour of the component rather than its implementation details, and leverage the automatic test generator of the underlying state-tranducer library. In particular :

As a result of these design goals :

API

makeVueStateMachine({name, renderWith, props, fsm, eventHandler, preprocessor, commandHandlers, effectHandlers, options, Vue })

Description

We expose a makeVueStateMachine Vue component factory which will return Vue component which implements the user interface specified by the parameters the factory receives. The parameters are as follows:

The created Vue component expects some props but does not expect children components.

Example

To showcase usage of our react component with our machine library, we will implement an image search application. That application basically takes an input from the user, looks up images related to that search input, and displays it. The user can then click on a particular image to see it in more details.

For illustration, the user interface starts like this :

image search interface

Click here for a live demo.

The user interface behaviour can be modelized by the following machine:

machine visualization

Let's see how to integrate that into a Vue codebase using our factory.

Encoding the machine graph

The machine is translated into the data structure expected by the supporting state-transducer library:

import { NO_OUTPUT } from "state-transducer";
import { COMMAND_SEARCH, NO_ACTIONS, NO_STATE_UPDATE } from "./properties";
import { applyJSONpatch, renderAction, renderGalleryApp } from "./helpers";

export const imageGalleryFsmDef = {
  events: [
    "START",
    "SEARCH",
    "SEARCH_SUCCESS",
    "SEARCH_FAILURE",
    "CANCEL_SEARCH",
    "SELECT_PHOTO",
    "EXIT_PHOTO"
  ],
  states: { init: "", start: "", loading: "", gallery: "", error: "", photo: "" },
  initialControlState: "init",
  initialExtendedState: {
    query: "",
    items: [],
    photo: undefined,
    gallery: ""
  },
  transitions: [
    { from: "init", event: "START", to: "start", action: NO_ACTIONS },
    { from: "start", event: "SEARCH", to: "loading", action: NO_ACTIONS },
    {
      from: "loading",
      event: "SEARCH_SUCCESS",
      to: "gallery",
      action: (extendedState, eventData, fsmSettings) => {
        const items = eventData;

        return {
          updates: [{ op: "add", path: "/items", value: items }],
          outputs: NO_OUTPUT
        };
      }
    },
    {
      from: "loading",
      event: "SEARCH_FAILURE",
      to: "error",
      action: NO_ACTIONS
    },
    {
      from: "loading",
      event: "CANCEL_SEARCH",
      to: "gallery",
      action: NO_ACTIONS
    },
    { from: "error", event: "SEARCH", to: "loading", action: NO_ACTIONS },
    { from: "gallery", event: "SEARCH", to: "loading", action: NO_ACTIONS },
    {
      from: "gallery",
      event: "SELECT_PHOTO",
      to: "photo",
      action: (extendedState, eventData, fsmSettings) => {
        const item = eventData;

        return {
          updates: [{ op: "add", path: "/photo", value: item }],
          outputs: NO_OUTPUT
        };
      }
    },
    { from: "photo", event: "EXIT_PHOTO", to: "gallery", action: NO_ACTIONS }
  ],
  entryActions: {
    loading: (extendedState, eventData, fsmSettings) => {
      const { items, photo } = extendedState;
      const query = eventData;
      const searchCommand = {
        command: COMMAND_SEARCH,
        params: query
      };
      const renderGalleryAction = renderAction({ query, items, photo, gallery: "loading" });

      return {
        outputs: [searchCommand].concat(renderGalleryAction.outputs),
        updates: NO_STATE_UPDATE
      };
    },
    photo: renderGalleryApp("photo"),
    gallery: renderGalleryApp("gallery"),
    error: renderGalleryApp("error"),
    start: renderGalleryApp("start")
  },
  updateState: applyJSONpatch,
}

Note:

A stateless component to render the user interface

The machine controls the user interface via the issuing of render commands, which include props for a user-provided Vue component. Here, those props are fed into GalleryApp, which renders the interface:

<template>
   <div class=".ui-app" v-bind:data-state="gallery">
       <Form v-bind:galleryState="gallery"
             v-bind:onSubmit="onSubmit"
             v-bind:onClick="onCancelClick">
       </Form>>
       <Gallery v-bind:galleryState="gallery"
                v-bind:items="items"
                v-bind:onClick="onGalleryClick">
       </Gallery>
       <Photo v-bind:galleryState="gallery"
              v-bind:photo="photo"
              v-bind:onClick="onPhotoClick">
       </Photo>
   </div>
</template>

<script>
 import Form from "./Form";
 import Gallery from "./Gallery";
 import Photo from "./Photo";

 export default {
   props : ["query", "photo", "items", "gallery", "next"],
   components: {
     Form,
     Gallery,
     Photo,
   },
   methods: {
     // reminder : do not use fat arrow functions!
     onSubmit: function(ev, formRef) {
       return this.next(["onSubmit", ev, formRef]);
     },
     onCancelClick: function(ev) {
       return this.next(["onCancelClick"])
     },
     onGalleryClick: function(item) {
       return this.next(["onGalleryClick", item]);
     },
     onPhotoClick: function(ev) {
       return this.next(["onPhotoClick"]);
     },
   }
 };
</script>

Note:

Implementing the user interface

We have our state machine defined, we have a component to render the user interface. We now have to implement the full user interface, e.g. processing events, and execute the appropriate commands in response. As we will use the makeVueStateMachine factory, we have to specify the corresponding props for it. Those props include, as the architecture indicates, an interface by which the user interface sends events to a preprocessor which transforms them into inputs for the state machine, which produces commands which are processed by command handlers, which delegate the actual effect execution to effect handlers:

import { COMMAND_SEARCH, NO_INTENT } from "./properties"
import {COMMAND_RENDER} from "vue-state-driven"
import { INIT_EVENT } from "state-transducer"
import  GalleryApp from "./GalleryApp"
import { destructureEvent, runSearchQuery } from "./helpers"
import { filter, map } from "rxjs/operators"
import { Subject } from "rxjs"

const stateTransducerRxAdapter = {
  subjectFactory: () => new Subject()
};

export const imageGalleryVueMachineDef = {
  props: ["query", "photo", "items", "gallery"],
  options: { initialEvent: ["START"] },
  renderWith: GalleryApp,
  eventHandler: stateTransducerRxAdapter,
  preprocessor: rawEventSource =>
    rawEventSource.pipe(
      map(ev => {
        const { rawEventName, rawEventData: e, ref } = destructureEvent(ev);

        if (rawEventName === INIT_EVENT) {
          return { [INIT_EVENT]: void 0 };
        }
        // Form raw events
        else if (rawEventName === "START") {
          return { START: void 0 };
        } else if (rawEventName === "onSubmit") {
          e.preventDefault();
          return { SEARCH: ref.current.value };
        } else if (rawEventName === "onCancelClick") {
          return { CANCEL_SEARCH: void 0 };
        }
        // Gallery
        else if (rawEventName === "onGalleryClick") {
          const item = e;
          return { SELECT_PHOTO: item };
        }
        // Photo detail
        else if (rawEventName === "onPhotoClick") {
          return { EXIT_PHOTO: void 0 };
        }
        // System events
        else if (rawEventName === "SEARCH_SUCCESS") {
          const items = e;
          return { SEARCH_SUCCESS: items };
        } else if (rawEventName === "SEARCH_FAILURE") {
          return { SEARCH_FAILURE: void 0 };
        }

        return NO_INTENT;
      }),
      filter(x => x !== NO_INTENT),
    ),
  commandHandlers: {
    [COMMAND_SEARCH]: (next, query, effectHandlers) => {
      effectHandlers
        .runSearchQuery(query)
        .then(data => {
          next(["SEARCH_SUCCESS", data.items]);
        })
        .catch(error => {
          next(["SEARCH_FAILURE", void 0]);
        });
    }
  },
  effectHandlers: {
    runSearchQuery: runSearchQuery,
    [COMMAND_RENDER]: (machineComponent, params, next) => {
      const props = Object.assign({}, params, { next, hasStarted: true });
      machineComponent.set(props);
    }
  }
};

Note:

The final application set-up

We now have all the pieces to integrate for our application:

import Vue from 'vue'
import { createStateMachine, decorateWithEntryActions, fsmContracts } from "state-transducer";
import { makeVueStateMachine } from "vue-state-driven";
import { imageGalleryVueMachineDef } from "./imageGalleryVueMachineDef";
import { imageGalleryFsmDef } from "./fsm"
import "./index.css";
import "./gallery.css";

Vue.config.productionTip = false

const fsmSpecsWithEntryActions = decorateWithEntryActions(
  imageGalleryFsmDef,
  imageGalleryFsmDef.entryActions,
  null
);
const fsm = createStateMachine(
  fsmSpecsWithEntryActions,
  { debug: { console, checkContracts: fsmContracts } }
);

makeVueStateMachine(Object.assign({ Vue, name: 'App', fsm }, imageGalleryVueMachineDef));

/* eslint-disable no-new */
new Vue({
  el: '#app',
  template: '<App/>'
})

Note:

A typical machine run

Alright, now let's leverage the example to explain the factory semantics.

Our state machine is basically a function which takes an input and returns outputs. The inputs received by the machine are meant to be mapped to events triggered by the user through the user interface. The outputs from the machine are commands representing what commands/effects to perform on the interfaced system(s). The mapping between user/system events and machine input is performed by preprocessor. The commands output by the machine are mapped to handlers gathered in commandHandlers so our Vue component knows how to run a command when it receives one.

A run of the machine would then be like this :

This is it! Whatever the machine passed as parameter to the makeVueStateMachine factory, its behaviour will always be as described.

Note that this example is contrived for educational purposes:

Types

Types contracts can be found in the repository.

Contracts

Semantics

Tips and gotchas

Prior art and useful references

Footnotes

  1. Command handlers can only perform effects internally (for instance async. communication with the mediator)

  2. In relation with state machines, it is the same to say that an output depends exclusively on past and present inputs and that an output exclusively depends on current state, and present input.

  3. Another term used elsewhere is deterministic functions, but we found that term could be confusing.