Skip to content

Latest commit

 

History

History
634 lines (538 loc) · 13.6 KB

README.md

File metadata and controls

634 lines (538 loc) · 13.6 KB

@rkrupinski/use-state-machine

A simple yet powerful finite state machine React hook.

Build status minified + gzip

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)

Table of contents:

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 Type Description
value string The name of the current state.
nextEvents string[] The names of possible events.

(see Events)
event Event The event that led to the current state.

(see Events)
context 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 Type Description
send (event: string | Event) => void Sends events to the machine

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,
});

Machine can be configured with the following options:

Name Type Description
initial (required) string The initial machine state value.

ℹ️ Note: Must be a key of states
states (required) { [key: string]: StateConfig } An object with configuration for all the states.

(see Configuring states)
context C (inferred) Initial context value.

(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 Type Description
on { [key: string]: string | EvtConfig } An object with configuration for all the transitions supported by this particular state.

(see Configuring state transitions)
effect Effect A callback fired once the machine has transitioned to a 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 a 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 Type Description
context C (inferred) The current value of the machine context.

(see Context)
setContext (updater: (context: C) => C) => void A function to update the value of context.

(see Context)
event Event The event that triggered the current machine state.

(see Events)
send (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 Type Description
target (required) string Target state value.

ℹ️ Note: Must be a key of states.

(see Configuring states)
guard 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 Type Description
event Event The event that triggered state transition.

(see Events)
context 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'

Further reading