Skip to content

kenkunz/svelte-fsm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Svelte FSM

A tiny, simple, expressive, pramgmatic Finite State Machine (FSM) library, optimized for Svelte.

  • tiny: under 1kb (minified); zero dependencies
  • simple: implements core FSM features, not the kitchen sink
  • expressive: FSM constructs are mapped to core JavaScript features (see Usage below)
  • pragmatic: prioritizes developer happiness over strict adherance to FSM or Statechart formalizations
  • Svelte-optimized: implements Svelte's store contract; philosophically aligned – feels at-home in a Svelte codebase

Usage

Svelte FSM's API is delightfully simple. FSM constructs are intuitively mapped to core JavaScript language features, resulting in a highly expressive API that's effortless to remember, a joy to write, and natural to read.

  • an fsm is defined by calling the default export fsm() function with 2 arguments: initial and states
  • states are just top-level object keys
  • events are invoked as function calls, and are defined as simple properties or functions
  • transitions are just property values or function return values
  • actions are just functions
  • timers (often used in state machines) are available by calling .debounce(wait) on any event
  • context is just… context (i.e., the lexical scope of your fsm)

See Full API below.

Examples

Simple on/off switch

View in Svelte REPL

import fsm from 'svelte-fsm';

const simpleSwitch = fsm('off', {
  off: { toggle: 'on'  },
  on:  { toggle: 'off' }
});

simpleSwitch.toggle(); // => 'on'
simpleSwitch.toggle(); // => 'off'

Traffic light using timers

View in Svelte REPL

import fsm from 'svelte-fsm';

const trafficLight = fsm('green', {
  green: {
    _enter() { this.change.debounce(20000); },
    change: 'yellow'
  },

  yellow: {
    _enter() { this.change.debounce(5000); },
    change: 'red'
  },

  red: {
    _enter() { this.change.debounce(20000); },
    change: 'green'
  }
});

trafficLight.subscribe(console.log);

// [ immiadetely  ] => 'green'
// [ after 20 sec ] => 'yellow'
// [ after  5 sec ] => 'red'
// [ after 20 sec ] => 'green'
// ...

Svelte form component

View and test a complete <form> component example with various states (entering, submitting, invalid, etc.).

View in Svelte REPL

Full API

Creating a state machine object

Svelte FSM exports a single default function. Import this as fsm, svelteFsm, or whatever seems appropriate in your project.

import fsm from 'svelte-fsm'

This function expects two arguments: initialState and states. The following is technically a valid but completely useless state machine:

const myFsm = fsm('initial', {});

States

Each state is a top-level property of the states object. A state's key can be any valid object property name (string or Symbol) except the wildcard '*' (see Fallback Actions below). A state's value should be an object with transition and action properties. The simplest state definition is just an empty object (you might use this for a final state where no further transitions or actions are possible).

const myFsm = fsm('initial', {
  initial: {
    finish: 'final'
  },

  final: {}
});

Transitions

As shown in the example above, a simple transition is defined by a key represending an event that can be invoked on the FSM object, and a value indicating the state to be transitioned to. In addition, action methods (see below) can optionally return a state value to be transitioned to. The following simple action-based transition is equivalent to the example above:

const myFsm = fsm('initial', {
  initial: {
    finish() { return 'final'; }
  },

  final: {}
});

Actions

As already mentioned, states can include methods called actions. An action may or may not result in a transition. Actions are useful for side-effects (requesting data, modifying context, generating output, etc.) as well as for conditional or (guarded) transitions. Since an action optionally returns a transition state, a single action might result in a transition in some circumstances and not others, and may result in different transitions. Actions can also optionally receive arguments.

const max = 10;
let level = 0;

const bucket = fsm('notFull', {
  notFull: {
    add(amount) {
      level += amount;
      if (level === max) {
        return 'full';
      } else if (level > max) {
        return 'overflowing';
      }
    }
  },

  full: {
    add(amount) {
      level += amount;
      return 'overflowing';
    }
  },

  overflowing: {
    add(amount) {
      level += amount;
    }
  }
});

Lifecycle Actions

States can also include two special lifecycle actions: _enter and _exit. These actions are only invoked when a transition occurs – _exit is invoked first on the state being exited, followed by _enter on the new state being entered.

Unlike normal actions, lifecycle methods cannot return a state transition (return values are ignored). These methods are called during a transition and cannot modify the outcome of it.

Lifecycle methods receive a single lifecycle metadata argument with the following properties:

{
  from: 'peviousState',  // the state prior to the transition.
  to: 'newState',        // the new state being transitioned to
  event: 'eventName',    // the name of the invoked event that resulted in the transition
  args: [ ... ]          // the arguments that were passed to the event
}

A somewhat special case is when a new state machine object is initialized. The _enter action is called on the initial state with a value of null for both the from and event properties, and an empty args array. This can be useful in case you want different entry behavior on initialization vs. when the state is re-entered.

const max = 10;
let level = 0;
let spillage = 0;

const bucket = fsm('notFull', {
  notFull: {
    add(amount) {
      level += amount;
      if (level === max) {
        return 'full';
      } else if (level > max) {
        return 'overflowing';
      }
    }
  },

  full: {
    add(amount) {
      level += amount;
      return 'overflowing';
    }
  },

  overflowing: {
    _enter({ from, to, event, args }) {
      spillage = level - max;
      level = max;
    }

    add(amount) {
      spillage += amount;
    }
  }
});

Fallback Actions

Actions may also be defined on a special fallback wildcard state '*'. Actions defined on the wildcard state will be invoked when no matching property exists on the FSM object's current state. This is true for both normal and lifecycle actions. This is useful for defining default behavior, which can be overridden within specific states.

Event invocation

Conceptually, invoking an event on an FSM object is asking it to do something. The object decides what to do based on what state it's in. The most natural syntax for asking an object to do something is simply a method call. Event invocations can include arguments, which are passed to matching actions.

myFsm.finish(); // => 'final'
bucket.add(10); // => 'full'

The resulting state of the object is returned from invocations. In addition, subscribers are notified if the state changes (see below).

Action method binding

Action methods are called with the this keyword bound to the FSM object. This enables you to invoke events from within the FSM's action methods, even before the resulting FSM object has been assigned to a variable.

When is it useful to invoke events within action methods? A common pattern is to initiate an asynchronous event from within a state's _enter action (e.g., a timed event using debounce, or a web request using fetch). The event invokes an action on the same state – e.g., success() or error(), resulting in an appropriate transition. The Traffic Light and Svelte Form States examples illustrate this scenario.

Making synchronous event calls within an action method is not recommended (this.someEvent()). Doing so may yield surprising results – e.g., if you invoke an event from an action that returns a state transition, and the invoked action also returns a transition, you are essentially making a nested transition. The outer transition (original action return value) will have the final say.

Note that arrow function expressions do not have their own this binding. You may use arrow functions as action properties, just don't expect this to reference the FSM object.

Debounced invocation

Events can be invoked with a delay by appending .debounce to any invocation. The first argument to debounce should be the wait time in milliseconds; subsequent arguemnts are forwarded to the action. If debounce is called again before the timer has completed, the original timer is canceled and replaced with the new one (even if the delay time is different).

bucket.add.debounce(2); // => Promise that resolves with 'overflowing'

debounce invocations return a Promise that resolves with the resulting state if the invocation executes. Canceled invocations (due to a subsequent debounce call) never resolve.

Subscribing to state changes

Svelte FSM adheres to Svelte's store contract. You can use this outside of Svelte components by calling subscribe with a callback (which returns an unsubscribe function).

const unsub = bucket.subscribe(console.log);
bucket.add(5); // [console] => 'notFull'
bucket.add(5); // [console] => 'full'
bucket.add(5); // [console] => 'overflowing'

Within a Svelte Component, you can use the $ syntactic sugar to access the current value of the store. Svelte FSM does not implement a set method, so you can't assign it directly. (This is intentional – finite state machines only change state based on the defined transitions and event invocations).

<div class={$bucket}>
  The bucket is {$bucket === 'notFull' ? 'not full' : $bucket}
</div>
<input type="number" bind:value>
<button type="button" on:click={() => bucket.add(value)}>

About

Tiny, expressive finite state machines for svelte

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •