@blockquote-web-components/blockquote-controller-xstate

中文文档
2.10.0发布于 2026/10/31MITTypeScriptES Module构建来源已验证

A modern architecture for building composable and reusable Web Components

原始使用文档
文档目录5 节
  1. Connect XState machines with Lit
  2. Demo
  3. Usage
  4. src/BlockquoteControllerXstate.ts :
  5. src/index.ts :

BlockquoteControllerXstate

Lit

Connect XState machines with Lit

The BlockquoteControllerXstate is a Lit Reactive Controller that is specifically designed to facilitate an integration with XState. This controller provides the capability to subscribe to an XState actor. It also calls onSnapshot with every state change.


Demo

Open in StackBlitz

Open in Stately.ai

Usage

counterMachine.ts

import {setup, types} from 'xstate';

const counterSetup = setup({
  // v6: `schemas` (Standard Schema). `types<T>()` is type-only, no runtime validation.
  // Zod, Valibot, ArkType... can be used instead to validate at runtime.
  schemas: {
    context: types<{counter: number}>(),
    events: {
      INC: types<void>(),
      DEC: types<void>(),
      TOGGLE: types<void>(),
    },
    // The element's public events, dispatched on the host with `dispatchEmitted`
    emitted: {
      counterchange: types<{counter: number}>(),
    },
  },
  guards: {
    canIncrement: ({context}) => context.counter < 10,
    canDecrement: ({context}) => context.counter > 0,
  },
  delays: {
    backoff: ({context}) => context.counter * 1000,
  },
});

export const counterMachine = counterSetup.createMachine({
  context: {counter: 0},
  initial: 'enabled',
  states: {
    enabled: {
      on: {
        // Transition functions (typed from `schemas`): return the next `context`, or
        // `undefined` to ignore the event. The guard comes first: a function that calls
        // `enq` handles the event even when it returns nothing.
        INC: ({context, guards}, enq) => {
          if (!guards.canIncrement({context})) {
            return undefined;
          }
          const counter = context.counter + 1;
          enq.emit({type: 'counterchange', counter});
          return {context: {counter}};
        },
        DEC: ({context, guards}, enq) => {
          if (!guards.canDecrement({context})) {
            return undefined;
          }
          const counter = context.counter - 1;
          enq.emit({type: 'counterchange', counter});
          return {context: {counter}};
        },
        // v6 types do not accept the string shorthand (`TOGGLE: 'disabled'`)
        TOGGLE: {target: 'disabled'},
      },
    },
    disabled: {
      after: {
        backoff: {target: 'enabled'},
      },
      on: {
        TOGGLE: {target: 'enabled'},
      },
    },
  },
});

new BlockquoteControllerXstate(this, {machine, options, onSnapshot, onError, reconnect, dispatchEmitted})

  • this: the host component (a Lit ReactiveControllerHost, usually the LitElement itself).
  • machine (required): the XState machine.
  • options: createActor options (input, inspect, ...). Optional, unless the machine requires input: then {input} or {snapshot} must be provided, as when calling createActor.
  • onSnapshot (optional): called with every new snapshot: the first one on each connection and the last one (snapshot.status === 'stopped') when the host is disconnected.
  • onError (optional): called when the actor errors (the 'error' snapshot is also sent to onSnapshot). If omitted, XState reports the error as unhandled, unless another subscriber of the actor handles it.
  • reconnect (optional): 'restart' (default) or 'restore', what the new actor starts from when the host is connected again.
  • dispatchEmitted (optional): dispatch the events the machine emits as DOM events on the host. true uses { composed: true }.

Machine with required input

const userMachine = setup({
  schemas: {
    input: types<{userId: string}>(),
    context: types<{userId: string}>(),
  },
}).createMachine({
  context: ({input}) => ({userId: input.userId}),
});

new BlockquoteControllerXstate(this, {machine: userMachine}); // type error: `input` is required
new BlockquoteControllerXstate(this, {machine: userMachine, options: {input: {userId: '123'}}});

Machines whose input accepts undefined (or that declare no input) can omit options.

The actor is created in the constructor, so snapshot, actor, send, trigger and can are available before the host is connected. It is started on hostConnected and stopped on hostDisconnected; a new actor is created if the host is connected again. With reconnect: 'restore' it resumes the state the previous actor had (pending timers keep their deadlines); by default ('restart') it starts from the initial state.

xstate-counter.js

import {html, LitElement} from 'lit';
import {BlockquoteControllerXstate} from '@blockquote-web-components/blockquote-controller-xstate';
import {counterMachine} from './counterMachine.js';
import {styles} from './styles/xstate-counter-styles.css.js';

export class XstateCounter extends LitElement {
  static styles = [styles];

  constructor() {
    super();
    // The controller re-renders the host on every snapshot. The machine emits the element's
    // public events, and `dispatchEmitted` dispatches them on it: `counterchange` with
    // `detail: {counter}`, `{composed: true}` (listen on the element, or capture).
    this.counterController = new BlockquoteControllerXstate(this, {
      machine: counterMachine,
      // Moving the element in the DOM keeps its state
      reconnect: 'restore',
      dispatchEmitted: true,
      options: {
        inspect: this._inspectEvents,
      },
    });
  }

  // xstate v6 inspection events: '@xstate.actor' | '@xstate.transition'
  _inspectEvents = (inspEvent) => {
    console.info('inspect event', inspEvent);
  };

  get #disabled() {
    return this.counterController.snapshot.matches('disabled');
  }

  render() {
    // `can.X()`: `false` when the event would be ignored (guards, `disabled` state) or the
    // actor has finished. `trigger` and `can` always target the current actor.
    const {can, trigger, snapshot} = this.counterController;
    return html`
      <slot></slot>
      <div data-disabled="${this.#disabled}">
        <span>
          <button
            ?disabled="${!can.INC()}"
            data-counter="increment"
            \@click=${() => trigger.INC()}>
            Increment
          </button>
          <button
            ?disabled="${!can.DEC()}"
            data-counter="decrement"
            \@click=${() => trigger.DEC()}>
            Decrement
          </button>
        </span>
        <p>${snapshot.context.counter}</p>
      </div>
      <div>
        <button \@click=${() => trigger.TOGGLE()}>
          ${this.#disabled ? 'Enabled counter' : 'Disabled counter'}
        </button>
      </div>
    `;
  }
}

trigger and can

trigger sends typed events generated from schemas.events: trigger.INC(), trigger.RENAME({label}), like send({type: 'RENAME', label}). It has the same signatures as XState v6's actor.trigger, with one difference: XState's belongs to one actor, while the controller's is a stable proxy (ControllerTrigger) that always sends to the current actor.

That matters because a new actor is created each time the host is connected again (stopped actors cannot restart). actor.trigger kept from the first connection would send to a stopped actor, and the event would be lost; controller.trigger can be kept or destructured safely.

can (ControllerCan) has the same shape, is also a stable proxy, and returns snapshot.can(event) for the current actor: can.INC(), can.RENAME({label}). It is false when the transition function ignores the event (returns undefined) or the actor has finished ('stopped', 'done' or 'error'). The machine still rejects invalid events: can only drives the UI.

Disconnect and reconnect: restart or restore

A Lit element is disconnected and connected again more often than it seems: moving it in the DOM, reordering a repeat, changing its parent. The actor is stopped on disconnect, and a stopped actor cannot restart, so a new actor is created on reconnect. The reconnect option decides what it starts from:

reconnectThe new actor starts from
'restart' (default)the initial state, or options.snapshot
'restore'the state the previous actor had: its persisted snapshot

With 'restore', the snapshot is persisted before the actor stops (actor.getPersistedSnapshot()) and the new actor is created from it (createActor(machine, {snapshot})). It keeps:

  • context, the state value and children.
  • Pending timers, with their original deadlines: a timer that expired while disconnected fires right away.
  • Invoked work in flight, such as a createAsyncLogic request: stopping aborts it, and the new actor runs it again.

An actor that had finished ('done' or 'error') restarts, and so does one whose machine was reassigned meanwhile. onSnapshot still receives the 'stopped' snapshot on disconnect, then the restored one.

Emitted events: on and dispatchEmitted

A machine emits events with enq.emit(...) to say that something happened once: the counter changed, a form was submitted. Declared in schemas.emitted, the element's public events live in the machine, typed and testable without a DOM.

dispatchEmitted: true dispatches each emitted event on the host as a CustomEvent: the emitted type is the event name and the rest is its detail, with {composed: true} (pass an object to change it). The event does not bubble: listen on the element itself, or on an ancestor in the capture phase ({capture: true}). EmittedEventMap types addEventListener:

declare global {
  interface HTMLElementEventMap extends EmittedEventMap<typeof counterMachine> {}
}

el.addEventListener('counterchange', (ev) => ev.detail.counter); // `number`

To handle emitted events in code, use controller.on(type, handler) (or '*'). It has the signature of actor.on, but XState removes actor.on handlers when the actor stops, while controller.on handlers keep receiving events from the actors created on reconnect.

When they are delivered:

  • After the transition is applied: controller.snapshot already has the new state (the host has not re-rendered yet).
  • Not when the host is disconnected (stopping the actor does not run exit), nor when an actor is restored with reconnect: 'restore' (entry does not run again): moving an element in the DOM dispatches nothing.
  • can.X() does not emit: it only evaluates the transition.

Listeners run synchronously, inside the trigger (or send) that caused the event:

  • controller.snapshot is already the new state, but the host has not re-rendered yet. To read its rendered DOM, await el.updateComplete first.
  • A trigger called from a listener is queued: it is processed when the current event is done, so controller.snapshot right after it is still the current state. Its own emitted events follow, in order.
  • A listener that throws is reported as an uncaught error. Other listeners still receive the event, and the actor keeps running.

controller.trigger vs controller.actor.trigger

Sends toAfter the host is connected again
controller.triggerthe current actor, looked up on each callreaches the new actor
controller.actor.triggerthe actor that was current when it was readreaches the stopped actor: the event is dropped (a warning in development builds)

Use controller.trigger for events from the UI. For a response to a request made from the component, it depends on reconnect:

  • 'restore': the new actor continues the same session, so a response that arrives after a reconnection is still valid. Use controller.trigger.
  • 'restart': the new actor starts a new session, so the response belongs to the old one. Read controller.actor.trigger before the await: the response is dropped instead of reaching the new actor.
// `reconnect: 'restart'`
async #onInput(ev: InputEvent) {
  const query = (ev.target as HTMLInputElement).value;
  this.search.trigger.typed({query});

  // Bound to this session: read before the `await`
  const session = this.search.actor.trigger;
  const items = await fetchSuggestions(query);

  // Dropped if the actor that asked was stopped in the meantime.
  // `this.search.trigger.results({items})` would show results for `query` in the new session.
  session.results({items});
}

Moving the request into the machine (an invoked createAsyncLogic) avoids the choice: the request belongs to the actor, and stopping the actor cancels it (its AbortSignal is aborted). With 'restore' the new actor runs it again; with 'restart' it is gone with the old session.


src/BlockquoteControllerXstate.ts:

class: BlockquoteControllerXstate

Fields
NamePrivacyTypeDefaultDescriptionInherited From
machineTMachinemachine
optionsControllerActorOptions<TMachine>['options']options`createActor` options. Typed like the constructor argument, so it cannot be reassigned without `input` when the machine requires it (a new actor is created on reconnect).
onSnapshot(snapshot: SnapshotFrom<TMachine>) => void | undefinedonSnapshot
onError(error: unknown) => void | undefinedonErrorWhether it is set decides, when the host connects, if XState reports errors as unhandled: adding or removing it applies from the next connection.
reconnect'restart' | 'restore'reconnectRead when the host is connected again.
actorRefActor<TMachine>
subscriptionSubscription | undefined
currentSnapshotSnapshotFrom<TMachine> | undefined
hostTHost
triggerControllerTrigger<TMachine>Typed event senders generated from `schemas.events`: `trigger.INC()`, `trigger['feedback.update']({ value })`, like `send({ type, ...payload })`. A stable proxy: it always sends to the current actor (a new one is created each time the host is connected again), so it can be kept or destructured. Not `actor.trigger`, which is bound to one actor. With `reconnect: 'restart'`, for an event that belongs to one session, such as the response to a request, read `actor.trigger` before the `await`: if the host was disconnected meanwhile, the response is dropped instead of reaching the new actor. With `'restore'` the session continues, so use `trigger`.
canControllerCan<TMachine>Typed `snapshot.can(...)` per event type, shaped like `trigger`: `can.INC()`, `can['feedback.update']({ value })`. `false` when the event would be ignored or the actor has finished (`'stopped'`, `'done'` or `'error'`). Before the host connects, it answers for the initial state. A stable proxy, like `trigger`: it always checks the current actor.
onActor<TMachine>['on']((type: string, handler: (event: EventObject) => void): Subscription => { const entry = {type, handler}; this.#emittedHandlers.add(entry); return { unsubscribe: () => { this.#emittedHandlers.delete(entry); }, }; }) as unknownHandles events the machine emits (`enq.emit`), like `actor.on(type, handler)`, with `'*'` for every event. Unlike `actor.on`, whose handlers are removed when the actor stops, the handler keeps receiving events from the actors created when the host is connected again.
actorActor<TMachine>The underlying ActorRef from XState. A new actor is created each time the host is connected again, so do not keep a reference to it across connections.
snapshotSnapshotFrom<TMachine>The latest snapshot of the actor's state
onNextInternal subscriber for state changes
isActiveboolean`true` while the current actor can be (or is being) run. A snapshot is `'active'` both before `start()` and while running; after `stop()` it is `'stopped'` (and `'done'` / `'error'` when it finishes or fails).
Methods
NamePrivacyDescriptionParametersReturnInherited From
sendSend an event to the actor serviceev: EventFrom<typeof this.machine>void
unsubscribevoid
startServiceStarts the actor. A new actor is created if the previous one is no longer active (e.g. stopped when the host was disconnected), because stopped actors cannot be restarted.void
stopServicevoid
hostConnectedvoid
hostDisconnectedvoid
Private API
Fields
NamePrivacyTypeDefaultDescriptionInherited From
#dispatchInitprivateDispatchEmittedOptions | undefined`dispatchEmitted` resolved to an `EventInit`, or `undefined` when it is off.
#emittedHandlersprivatenew Set<{ type: string; handler: (event: EventObject) => void; }>()
#emittedSubscriptionprivateSubscription | undefined
#persistedprivate`{
machine: TMachine;
snapshot: ActorOptions<TMachine>['snapshot'];

} | undefined | | Persisted on disconnect with \reconnect: 'restore'`, with the machine it belongs to. | | | #onEmitted | private | | | Delivers an event emitted by the current actor to the DOM and to `on` handlers. | |

Methods
NamePrivacyDescriptionParametersReturnInherited From
#createActorprivatesnapshot: ActorOptions<TMachine>['snapshot']Actor<TMachine>
#takePersistedprivateThe snapshot to restore on reconnect: only with `reconnect: 'restore'`, and only for the machine it was persisted from (`machine` may have been reassigned meanwhile).ActorOptions<TMachine>['snapshot']

Exports

KindNameDeclarationModulePackage
jsBlockquoteControllerXstateBlockquoteControllerXstatesrc/BlockquoteControllerXstate.ts

src/index.ts:

Exports

KindNameDeclarationModulePackage
jsBlockquoteControllerXstateBlockquoteControllerXstate./BlockquoteControllerXstate.js
jsBlockquoteControllerXstateOptionsBlockquoteControllerXstateOptions./BlockquoteControllerXstate.js
jsControllerCanControllerCan./BlockquoteControllerXstate.js
jsControllerTriggerControllerTrigger./BlockquoteControllerXstate.js
jsDispatchEmittedOptionsDispatchEmittedOptions./BlockquoteControllerXstate.js
jsEmittedEventMapEmittedEventMap./BlockquoteControllerXstate.js
jsUseMachineOptionsUseMachineOptions./BlockquoteControllerXstate.js

版本与发布记录

@blockquote-web-components/blockquote-controller-xstate 共发布 70 个版本,首次发布于 2023/11/26;过去 12 个月发布了 21 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.10.0latest2026/10/312 天前
2.9.02026/10/312 天前
2.8.12026/10/313 天前
2.8.02026/10/1024 天前
2.7.02026/09/261 个月前
2.6.02026/09/251 个月前

关于 @blockquote-web-components/blockquote-controller-xstate 的常见问题

@blockquote-web-components/blockquote-controller-xstate 是什么?

用于构建可组合和可重用Web组件的现代体系结构

如何安装 @blockquote-web-components/blockquote-controller-xstate?

在项目目录运行 npm install @blockquote-web-components/blockquote-controller-xstate,或使用 pnpm add / yarn add / bun add。

@blockquote-web-components/blockquote-controller-xstate 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@blockquote-web-components/blockquote-controller-xstate 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@blockquote-web-components/blockquote-controller-xstate 可以商用吗?

@blockquote-web-components/blockquote-controller-xstate 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@blockquote-web-components/blockquote-controller-xstate 还在维护吗?

最新版本 2.10.0 发布于 2026/10/31(2 天前);至今共发布 70 个版本,其中过去 12 个月发布了 21 个;首次发布于 2023/11/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。