Skip to content

NullVoxPopuli/ember-statechart-component

Repository files navigation

ember-statechart-component

CI npm version

Use XState Machines as components.

Support

  • XState >= 5
  • TypeScript >= 5
  • ember-source >= 5.1
  • Glint >= 1.2.1

Installation

npm install ember-statechart-component

To be able to use XState state.matches method in our templates, we will first need ember-source@4.5+ or a HelperManager for handling vanilla functions. ember-functions-as-helper-polyfill provides one:

npm install ember-functions-as-helper-polyfill

In app/app.js / app/app.ts, a one time setup function will need to be called so that the ComponentManager is registered.

import Application from '@ember/application';

import config from 'ember-app/config/environment';
import loadInitializers from 'ember-load-initializers';
import Resolver from 'ember-resolver';

import { setupComponentMachines } from 'ember-statechart-component';

export default class App extends Application {
  modulePrefix = config.modulePrefix;
  podModulePrefix = config.podModulePrefix;
  Resolver = Resolver;
}

loadInitializers(App, config.modulePrefix);

setupComponentMachines();

Usage

Example with Ember Octane

// app/components/toggle.js
import { createMachine } from 'xstate';

export default createMachine({
  initial: 'inactive',
  states: {
    inactive: { on: { TOGGLE: 'active' } },
    active: { on: { TOGGLE: 'inactive' } },
  },
});

Usage:

<Toggle as |state send|>
  {{state.value}}

  <button {{on 'click' (fn send 'TOGGLE')}}>
    Toggle
  </button>
</Toggle>

The default template for every createMachine(..) is

{{yield this.state this.send}}

but that can be overriden to suit your needs by defining your own template. The this is an instance of the XState Interpreter

Accessing EmberJS Services

// app/components/authenticated-toggle.js
import { getService } from 'ember-statechart-component';
import { createMachine } from 'xstate';

export default createMachine({
  initial: 'inactive',
  states: {
    inactive: {
      on: {
        TOGGLE: [
          {
            target: 'active',
            cond: 'isAuthenticated',
          },
          { actions: ['notify'] },
        ],
      },
    },
    active: { on: { TOGGLE: 'inactive' } },
  },
}, {
  actions: {
    notify: (ctx) => {
      getService(ctx, 'toasts').notify('You must be logged in');
    },
  },
  guards: {
    isAuthenticated: (ctx) => getService(ctx, 'session').isAuthenticated,
  },
});

Usage:

<AuthenticatedToggle as |state send|>
  {{state.value}}

  <button {{on 'click' (fn send 'TOGGLE')}}>
    Toggle
  </button>
</AuthenticatedToggle>

Matching States

<Toggle as |state send|>
  {{#if (state.matches 'inactive')}}
    The inactive state
  {{else if (state.matches 'active')}}
    The active state
  {{else}}
    Unknown state
  {{/if}}

  <button {{on 'click' (fn send 'TOGGLE')}}>
    Toggle
  </button>
</Toggle>

Glint

Having type checking with these state machines can be done automatically after importing the /glint file in your types/<app-name>/glint-registry.d.ts.

import "@glint/environment-ember-loose";
import "@glint/environment-ember-loose/native-integration";
import "ember-page-title/glint";

// This import extends the type of `StateMachine` to be glint-compatible
import 'ember-statechart-component/glint';

declare module "@glint/environment-ember-loose/registry" {
  export default interface Registry {
    // How to define globals from external addons
  }
}

API

@config

This argument allows you to pass a MachineOptions for actions, services, guards, etc.

Usage:

Toggle machine that needs a config
// app/components/toggle.js
import { createMachine, assign } from 'xstate';

export default createMachine({
  initial: 'inactive',
  states: {
    inactive: { on: { TOGGLE: 'active' } },
    active: {
      on: {
        TOGGLE: {
          target: 'inactive',
          actions: ['toggleIsOn']
        }
      }
    },
  },
});
<Toggle
  @config={{hash
    actions=(hash
      toggleIsOn=@onRoomIlluminated
    )
  }}
as |state send|>
  <button {{on 'click' (fn send 'TOGGLE')}}>
    Toggle
  </button>
</Toggle>

@context

Sets the initial context. The current value of the context can then be accessed via state.context.

Usage:

Toggle machine that interacts with context
// app/components/toggle.js
import { createMachine, assign } from 'xstate';

export default createMachine({
  initial: 'inactive',
  states: {
    inactive: {
      on: {
        TOGGLE: {
          target: 'active',
          actions: ['increaseCounter']
        }
      }
    },
    active: {
      on: {
        TOGGLE: {
          target: 'inactive',
          actions: ['increaseCounter']
        }
      }
    },
  },
}, {
  actions: {
    increaseCounter: assign({
      counter: (context) => context.counter + 1
    })
  }
});
<Toggle @context=(hash counter=0) as |state send|>
  <button {{on 'click' (fn send 'TOGGLE')}}>
    Toggle
  </button>

  <p>
    Toggled: {{state.context.counter}} times.
  </p>
</Toggle>

@state

The machine will use @state as the initial state. Any changes to this argument are not automatically propagated to the machine. An ARGS_UPDATE event (see details below) is sent instead.

What happens if any of the passed args change?

An event will be sent to the machine for you, ARGS_UPDATE, along with all named arguments used to invoke the component.

Compatibility

  • ember-source v3.28+
  • typescript v4.5+
  • ember-auto-import v2+
  • A browser that supports Proxy
  • Glint 0.8.3+
    • Note that updates to glint support will not be covered by this library's adherance to SemVer. All glint-related updates will be bugfixes until Glint is declared stable.

Contributing

See the Contributing guide for details.

License

This project is licensed under the MIT License.