npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

Iโ€™ve always been into building performant and accessible sites, but lately Iโ€™ve been taking it extremely seriously. So much so that Iโ€™ve been building a tool to help me optimize and monitor the sites that I build to make sure that Iโ€™m making an attempt to offer the best experience to those who visit them. If youโ€™re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, ๐Ÿ‘‹, Iโ€™m Ryan Hefnerย  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If youโ€™re interested in other things Iโ€™m working on, follow me on Twitter or check out the open source projects Iโ€™ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soonโ€“ish.

Open Software & Tools

This site wouldnโ€™t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you ๐Ÿ™

ยฉ 2026 โ€“ย Pkg Stats / Ryan Hefner

statry

v1.4.3

Published

๐Ÿ”€ Type-safe state machine description and runtime library

Readme

Statry

๐Ÿ”€ Type-safe state machine description and runtime library

Statry lets you describe a finite state machine as a plain object (states, events, transitions, and per-state lifecycle hooks) and run it with a small StateMachine class. The definition drives full TypeScript inference, so events, states, and context are all checked at compile time.

Features

  • Type-safe by construction: states and events are inferred from the definition; unknown event types or transitions to unknown states are caught by the compiler.
  • Plain-object definitions: a state machine is a nested record: { [stateType]: { [eventType]: handler, [ENTER]: hook } }. No builders, no complex API.
  • Lifecycle hooks with cleanup: the ENTER hook runs when a state is entered and may return a cleanup function that runs when the state is left. This is useful for timers, subscriptions, or listeners.
  • Runtime event stream: the machine extends TypedEventEmitter, dispatching statetransition, selftransition, and ignoredevent so you can observe or react to what the machine is doing.
  • Context. An optional context object is threaded through every handler for data that outlives a single transition.
  • Tiny surface: one class (StateMachine), one symbol (ENTER), and a handful of types.

Usage

Everything is exported from the main entry-point:

import { ENTER, StateMachine, type Definition } from "statry";

Examples

A minimal drag-and-drop

The simplest definition is just states and their event handlers. Each handler returns the next state:

import { StateMachine, type Definition } from "statry";

const DEFINITION: Definition<
  { type: "idle" } | { type: "drag" },
  { type: "mousedown" } | { type: "mouseup" },
  never
> = {
  idle: {
    mousedown: () => ({ type: "drag" }),
  },
  drag: {
    mouseup: () => ({ type: "idle" }),
  },
};

const machine = new StateMachine(DEFINITION, { type: "idle" });

machine.send({ type: "mousedown" }); // machine.state is now { type: "drag" }
machine.send({ type: "mouseup" }); // back to { type: "idle" }

The three type parameters in Definition<States, Events, Context> drive inference for every handler in the object. Sending an event that isn't in the union, or returning a state that isn't, is a compile-time error.

[!TIP] The state, event, and context types can also be supplied directly as type parameters on the StateMachine constructor, in which case the definition is checked against them without needing a separate Definition type. This is convenient when the definition is inlined at the call site:

import { StateMachine } from "statry";

const machine = new StateMachine<
  { type: "idle" } | { type: "drag" },
  { type: "mousedown" } | { type: "mouseup" },
  never
>(
  {
    idle: {
      mousedown: () => ({ type: "drag" }),
    },
    drag: {
      mouseup: () => ({ type: "idle" }),
    },
  },
  { type: "idle" },
);

Prefer Definition<...> when the definition lives in its own binding and you want its literal shape to drive inference at every call site; prefer the constructor form when the state, event, and context unions are the authoritative source of truth and the definition is a one-shot argument.

Auto-release with a timer

To do work when a state is entered, define an ENTER handler. It receives the transition event, the new state, and the context. Use event.target.send to send events to the current state machine. If the handler returns a function, that function is called when the state is left, allowing timers and subscriptions to be cleaned up:

import { ENTER, StateMachine, type Definition } from "statry";

const DEFINITION: Definition<
  { type: "idle" } | { type: "drag" },
  { type: "mousedown" } | { type: "mouseup" },
  never
> = {
  idle: {
    mousedown: () => ({ type: "drag" }),
  },
  drag: {
    [ENTER]: (event, state, context) => {
      const id = setTimeout(() => event.target.send({ type: "mouseup" }), 3000);
      return () => clearTimeout(id);
    },
    mouseup: () => ({ type: "idle" }),
  },
};

const machine = new StateMachine(DEFINITION, { type: "idle" });
machine.send({ type: "mousedown" }); // auto-releases 3 seconds later

If the user releases early with a real mouseup, clearTimeout cancels the pending self-send.

[!TIP] Instead of using setTimeout and clearTimeout, you can also use timeout from Futurise:

import { ENTER, type Definition } from "statry";
import { timeout } from "futurise";

const DEFINITION: Definition<
 { type: "idle" } | { type: "drag" },
 { type: "mousedown" } | { type: "mouseup" },
 never
= {
 idle: {
   mousedown: () => ({ type: "drag" }),
 },
 drag: {
   [ENTER]: (event, state, context) => timeout(3000, () => event.target.send({ type: "mouseup" })),
   mouseup: () => ({ type: "idle" }),
 },
}

The XState v5 equivalent uses after to describe delayed transitions:

import { createActor, createMachine } from "xstate";

const dragMachine = createMachine({
  id: "drag",
  initial: "idle",
  states: {
    idle: {
      on: { mousedown: "drag" },
    },
    drag: {
      after: { 3000: "idle" },
      on: { mouseup: "idle" },
    },
  },
});

const actor = createActor(dragMachine).start();
actor.send({ type: "mousedown" });

XState treats after as a first-class concept, while Statry expresses the same behavior with a plain setTimeout inside ENTER. The XState version reads more declaratively; the Statry version keeps the timer as ordinary JavaScript, which composes with any other API that returns a cleanup function (such as the one provided by Futurise).

Guards for conditional transitions

A transition handler is just a function, so a guard is expressed by returning the current state when a condition fails. Here a locked door only unlocks when the correct code is passed with the event:

import { StateMachine } from "statry";

type DoorState = { type: "locked" } | { type: "unlocked" } | { type: "open" };

type DoorEvent =
  | { type: "unlock"; code: string }
  | { type: "lock" }
  | { type: "open" }
  | { type: "close" };

type DoorContext = { code: string };

const DEFINITION: Definition<DoorState, DoorEvent, DoorContext> = {
  locked: {
    unlock: (event, state, context) =>
      event.code === context.code ? { type: "unlocked" } : state,
  },
  unlocked: {
    lock: () => ({ type: "locked" }),
    open: () => ({ type: "open" }),
  },
  open: {
    close: () => ({ type: "unlocked" }),
  },
};

const machine = new StateMachine(
  DEFINITION,
  { type: "locked" },
  { code: "0000" },
);

machine.send({ type: "unlock", code: "1234" }); // still { type: "locked" }
machine.send({ type: "unlock", code: "0000" }); // now { type: "unlocked" }

When the guard fails, no statetransition fires. Observers still see a selftransition, so failed attempts remain visible if you want to react to them.

A stopwatch with context

States and events carry a type discriminator, but they can also carry data. Any value that changes as the machine runs belongs on the state itself: each transition returns a fresh state object, and no handler ever mutates what it has been given. The third type parameter is a shared context object, reserved for immutable configuration and dependencies that don't change over the machine's lifetime. This stopwatch keeps the accumulated elapsed milliseconds on every state, tracks the current run's startedAt on running, and reads its tick interval from the context:

import { ENTER, StateMachine } from "statry";
import { interval } from "futurise";

type StopwatchState =
  | { type: "idle"; elapsed: number }
  | {
      type: "running";
      elapsed: number;
      baseElapsed: number;
      startedAt: number;
    };

type StopwatchEvent =
  { type: "start" } | { type: "pause" } | { type: "reset" } | { type: "tick" };

type StopwatchContext = { tickInterval: number };

const stopWatch = new StateMachine<
  StopwatchState,
  StopwatchEvent,
  StopwatchContext
>(
  {
    idle: {
      start: (event, state) => ({
        type: "running",
        elapsed: state.elapsed,
        baseElapsed: state.elapsed,
        startedAt: Date.now(),
      }),
      reset: () => ({ type: "idle", elapsed: 0 }),
    },
    running: {
      [ENTER]: (event, state, context) =>
        interval(context.tickInterval, () =>
          event.target.send({ type: "tick" }),
        ),
      tick: (event, state) => ({
        ...state,
        elapsed: state.baseElapsed + (Date.now() - state.startedAt),
      }),
      pause: (event, state) => ({ type: "idle", elapsed: state.elapsed }),
      reset: () => ({ type: "idle", elapsed: 0 }),
    },
  },
  { type: "idle", elapsed: 0 },
  { tickInterval: 100 },
);

stopWatch.addEventListener("statetransition", (event) => {
  console.log(event.previousState.type, "โ†’", event.state.type);
});

stopWatch.addEventListener("selftransition", (event) => {
  if (event.state.type === "running") {
    console.log("tick", event.state.elapsed);
  }
});

stopWatch.send({ type: "start" });

The baseElapsed and startedAt state properties are frozen at the moment start fires and stay constant through the whole run, so each tick handler just derives elapsed = baseElapsed + (Date.now() - startedAt) from them and returns a new running state. The ENTER handler only registers the interval and returns its cleanup. It never writes back to state or context. On every interval firing, event.target.send({ type: "tick" }) re-enters the machine, producing a selftransition that observers can react to through the runtime event stream. Context stays constant throughout: the interval hook reads tickInterval but nothing writes to it.

The XState v5 equivalent packs the same runtime data into context, updates it through assign actions, and invokes a callback for the ticking interval:

import { assign, createActor, createMachine, fromCallback } from "xstate";

type StopwatchContext = {
  elapsed: number;
  baseElapsed: number;
  startedAt: number;
  tickInterval: number;
};

type StopwatchEvent =
  { type: "start" } | { type: "pause" } | { type: "reset" } | { type: "tick" };

const stopwatchMachine = createMachine({
  id: "stopwatch",
  initial: "idle",
  context: { elapsed: 0, baseElapsed: 0, startedAt: 0, tickInterval: 100 },
  types: {
    context: {} as StopwatchContext,
    events: {} as StopwatchEvent,
  },
  states: {
    idle: {
      on: {
        start: {
          target: "running",
          actions: assign({
            baseElapsed: ({ context }) => context.elapsed,
            startedAt: () => Date.now(),
          }),
        },
        reset: {
          actions: assign({ elapsed: 0 }),
        },
      },
    },
    running: {
      invoke: {
        src: fromCallback(({ sendBack, input }) => {
          const id = setInterval(
            () => sendBack({ type: "tick" }),
            input.tickInterval,
          );
          return () => clearInterval(id);
        }),
        input: ({ context }) => ({ tickInterval: context.tickInterval }),
      },
      on: {
        tick: {
          actions: assign({
            elapsed: ({ context }) =>
              context.baseElapsed + (Date.now() - context.startedAt),
          }),
        },
        pause: "idle",
        reset: {
          target: "idle",
          actions: assign({ elapsed: 0 }),
        },
      },
    },
  },
});

const actor = createActor(stopwatchMachine).start();
actor.send({ type: "start" });

XState packs every mutable value (including tickInterval) into a single context and updates it through assign actions attached to transitions. Statry takes the opposite stance: runtime values live on the state itself, context holds only immutable configuration, and every transition returns a fresh state object. The ENTER handler only registers the interval and returns its cleanup; no handler ever writes back to state or context.

Composing multiple state machines

Because StateMachine extends TypedEventEmitter, one machine's statetransition, selftransition, and ignoredevent events can drive another machine's transitions. There is no special coordinator: a plain listener that calls send on the peer is enough.

Here a heartbeat machine starts pinging when a connection machine reaches connected, and stops when it goes back to disconnected:

import { ENTER, StateMachine, type Definition } from "statry";
import { interval } from "futurise";

const connectionMachine = new StateMachine<
  { type: "disconnected" } | { type: "connected" },
  { type: "connect" } | { type: "disconnect" }
>(
  {
    disconnected: {
      connect: () => ({ type: "connected" }),
    },
    connected: {
      disconnect: () => ({ type: "disconnected" }),
    },
  },
  {
    type: "disconnected",
  },
);

const heartbeatMachine = new StateMachine<
  { type: "off" } | { type: "on" },
  { type: "start" } | { type: "stop" }
>(
  {
    off: {
      start: () => ({ type: "on" }),
    },
    on: {
      [ENTER]: () => interval(1000, () => console.log("ping")),
      stop: () => ({ type: "off" }),
    },
  },
  { type: "off" },
);

connectionMachine.addEventListener("statetransition", (event) => {
  if (event.state.type === "connected") {
    heartbeatMachine.send({ type: "start" });
  } else {
    heartbeatMachine.send({ type: "stop" });
  }
});

connectionMachine.send({ type: "connect" }); // heartbeat starts pinging
connectionMachine.send({ type: "disconnect" }); // heartbeat stops

[!TIP] A state machine can also directly handle the events emitted by another time machine:

import {
  ENTER,
  StateMachine,
  type Definition,
  type RuntimeEvent,
} from "statry";

const heartbeatMachine = new StateMachine<
  { type: "off" } | { type: "on" },
  Extract<RuntimeEvent<typeof connectionMachine>, { type: "statetransition" }>
>(
  {
    off: {
      statetransition: (event, state) =>
        event.state.type === "connected" ? { type: "on" } : state,
    },
    on: {
      [ENTER]: () => interval(1000, () => console.log("ping")),
      statetransition: (event, state) =>
        event.state.type === "disconnected" ? { type: "off" } : state,
    },
  },
  { type: "off" },
);

connectionMachine.addEventListener("statetransition", heartbeatMachine.send);

The same pattern scales to any number of peers, and the event stream itself is fully typed, so both event.state and event.trigger narrow to the peer's declared unions.

[!TIP] When to reach for XState instead

XState is a much larger library with a rich feature set: hierarchical and parallel states, guards, invoked actors, delayed transitions, history states, and a visual editor. Statry can express the same behaviors, just without dedicated syntax for each one:

  • Guards are conditions inside a transition handler that return the current state when they fail (see the door example).
  • Delayed transitions are setTimeout calls inside ENTER whose cleanup is clearTimeout (see the auto-release example).
  • Invoked side effects are anything an ENTER handler starts and its cleanup tears down: intervals, subscriptions, AbortController, addEventListener.
  • Parallel states come from composing peer machines through the event emitter (see the connection and heartbeat example); each region is its own StateMachine and they coordinate through send.
  • Hierarchical states can be modelled by nesting a child StateMachine inside the context of a parent, or by encoding a substate as a payload on the outer state's type.

Everything Statry offers is built from plain functions, closures, and event listeners, and typed against a single Definition<States, Events, Context>, without decorators, schema helpers, or a setup({ types }) call. Reach for XState when you want the declarative vocabulary and its ecosystem (visual editor, actor model, model checking); reach for Statry when a plain-object definition, strong types, and composability through plain event listeners are enough.

Installation

Install with the Node Package Manager:

npm install statry

Documentation

Documentation is generated here.

Use the application to test the library

  • npm run dev
  • npm run dev:test (or use dedicated Vitest plugin of your IDE)

Import exported library items from the "#lib" alias:

import { StateMachine } from "#lib";

Build and publish the library

  • npm run build:lib
  • Set the private property to false in package.json
  • npm run release:init

Release subsequent versions using either

  • npm run release:alpha
  • npm run release:beta
  • npm run release:patch
  • npm run release:minor
  • npm run release:major