Sunday, 21 November, 2021 UTC


Summary

@rkrupinski/use-state-machine
A simple yet powerful finite state machine React hook.
const [state, send] = useStateMachine({
  initial: "enabled",
  states: {
    enabled: {
      on: { TOGGLE: "disabled" },
    },
    disabled: {
      on: { TOGGLE: "enabled" },
    },
  },
});

Comes packed with features like:
  • effects (state entry/exit)
  • guards (allow/prevent state transitions)
  • extended state (context)
  • good to very good TypeScript experience (see History)
History
This project was born as an attempt to reimplement @cassiozen/usestatemachine in a more "friendly" way. Despite only weighing <1kB, I found the reference project being slightly overly complex, especially on the type system side of things.
ℹ️ Note: This is based on version 1.0.0-beta.4 (source code)
Differences compared to the reference project:
  • simpler implementation
  • simpler types (with added benefit of making invalid/orphan states impossible)
  • manual payload typing/decoding (in place of "schema"; see Event payload for details)
  • manual context typing (in place of "schema"; see Context for details)
Installation
npm install @rkrupinski/use-state-machine
Examples
View source code or live.
Examples cover:
  • a basic machine with context and guards
  • sending events with payload
  • http with error recovery
API

State

const [
  state, // <--- this guy
  send,
] = useStateMachine(/* ... */);
state is an object of the following shape:
Name Description
value Type: string

The name of the current state.
nextEvents Type: string[]

The names of possible events.

(see Events)
event Type: Event

The event that led to the current state.

(see Events)
context Type: C (inferred)

Machine's extended state. Think of it as a place to store additional, machine-related data throughout its whole lifecycle.

(see Context)

Events

const [
  state,
  send, // <--- this guy
] = useStateMachine(/* ... */);
Once initialized, events can be sent to the machine using the send function.
Name Description
send Type: (event: string | Event) => void
When sending events you can either use a shorthand (string) syntax:
send("START");
or the object (Event) syntax:
send({ type: "START" });
Under the hood, all sent events are normalized to objects (Event).
ℹ️ Note: The reason behind having 2 formats is that events, apart from being of certain type, can also carry payload.
(see Event payload)

Machine options

const [state, send] = useStateMachine({
  initial: "idle",
  states: {
    /* ... */
  },
  context: 42,
});
Name Description
initial (required) Type: string

The initial machine state value.

ℹ️ Note: Must be a key of states
states (required) Type: { [key: string]: StateConfig }

An object with configuration for all the states.

(see Configuring states)
context Type: C

Initial context value.

ℹ️ Note: Used for inferring context type.

(see Context)

Configuring states

You can configure individual states using the states field of the machine options.
const [state, send] = useStateMachine({
  /* ... */
  states: {
    idle: {
      on: {
        START: "running",
      },
      effect() {
        console.log("idling");
      },
    },
    /* ... */
  },
});
Keys of the states object are state names, values are StateConfig object of the following shape:
Name Description
on Type: { [key: string]: string | EvtConfig }

An object with configuration for all the transitions supported by this particular state.

(see Configuring state transitions)
effect Type: Effect

A callback fired once the machine has transitioned to this particular state.

(see Effects)
ℹ️ Note: There can't be a state that's neither initial, nor can be transitioned to.

Effects

You can define a callback to fire once the machine has transitioned to this particular state using the effect field.
const [state, send] = useStateMachine({
  /* ... */
  states: {
    idle: {
      effect({ context, setContext, event, send }) {
        console.log("idling due to", event.type);

        return () => {
          console.log("idling no more");
        };
      },
    },
    /* ... */
  },
});
The effect callback will receive an object of the following shape:
Name Description
context Type: C (inferred)

The current value of the machine context.

(see Context)
setContext Type: (updater: (context: C) => C) => void

A function to update the value of context.

(see Context)
event Type: Event

The event that led to the current state.

(see Events)
send Type: (event: string | Event) => void

A function to send events to the machine.

(see Events)
If the return value from effect is of type function, that function will be executed when the machine transitions away from the current state (exit/cleanup effect):
effect() {
  console.log('entered a state');

  return () => {
    console.log('exited a state');
  };
},
ℹ️ Note: Events are processed synchronously while effects are asynchronous. In other words, if several events are sent synchronously, e.g.:
send("ONE");
send("TWO");
send("THREE");
state transitions will be performed accordingly, yet only the effect for state triggered by THREE (if defined) will be executed.

Configuring state transitions

For every state you can configure when and if a transition to a different state should be performed. This is done via the on property of StateConfig.
const [state, send] = useStateMachine({
  /* ... */
  states: {
    idle: {
      on: {
        START: "running",
        FUEL_CHECK: {
          target: "off",
          guard() {
            return isOutOfFuel();
          },
        },
      },
    },
    off: {},
  },
});
Transition config can either be a string (denoting the target state value) or an object of the following shape:
Name Description
target (required) Type: string

Target state value.

ℹ️ Note: Must be a key of states.

(see Configuring states)
guard Type: Guard

A boolean-returning function to determine whether state transition is allowed.

(see Guards)

Guards

The purpose of guards is to determine whether state transition is allowed. A guard function is invoked before performing state transition and depending on its return value:
  • true ➡️ transition is performed
  • false ➡️ transition is prevented
A guard function will receive an object of the following shape:
Name Description
event Type: Event

The event that triggered state transition.

(see Events)
context Type: C (inferred)

The current value of the machine context.

(see Context)
Event payload
When using the object (Event) syntax, you can send events with payload like so:
send({
  type: "REFUEL",
  payload: { gallons: 5 },
});
The payload can be then consumed from:
  • the state object (see State)
  • effect functions (see Effects)
  • guard functions (see Guards)
How is it typed though? Is the type of payload inferred correctly?
For several reasons, the most important of which is simplicity (see History), this library does neither aim at inferring, nor allows providing detailed event types. Instead, it encourages using other techniques, like:
  • Duck typing
  • Type guards
  • Decoders
The payload (event.payload) is always typed as unknown and it's up to the consumer to extract all the required information from it.
Here's an example of a guard function that only allows refueling if the number of gallons is at least 5, using io-ts to decode the payload:
import * as t from "io-ts";
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/Either';

const RefuelPayload = t.type({
  gallons: t.number,
});

/* ... */

guard({ event }) {
  const gallons = pipe(
    RefuelPayload.decode(event.payload),
    fold(
      () => 0,
      p => p.gallons,
    ),
  );

  return gallons >= 5;
}
Context
As mentioned above, the type of context is inferred from the initial value (see Machine options).
Type inference is straightforward for basic types like:
  • 42 ➡️ number
  • 'context' ➡️ string
  • [1, 2, 3] ➡️ number[]
It gets tricky though if you need more complex constructs like:
  • type narrowing ('foo' vs string)
  • optionality ({ foo?: string })
  • unions ('foo' | 'bar')
Again, complex inference and annotating all the things through generic parameters is beyond the scope of this library (see History). What it encourages instead is "hinting" TypeScript on the actual type of context.
This can be done via type assertions:
type ContextType = "foo" | "bar";

const [state, send] = useStateMachine({
  /* ... */
  context: "foo" as ContextType,
});

state.context; // 'foo' | 'bar'
GitHub
https://github.com/rkrupinski/use-state-machine