statekit • Page 11 of 14

Decorations

17/10/2026 • 3 min read

A decoration is reusable behaviour for machines. It takes a machine config and returns it with more events and selectors.

It never adds data or states. The machine keeps the shape its config declares, and the decoration is told, through slots, where in that shape to work.

Using one

A decoration is a function of the config and its options. Pass what it returns to createMachine. Here withLoad loads something into a field and reports progress in the machine's states:

import { createMachine } from "@statekit/core"
import { withLoad } from "./withLoad"

type User = { name: string }

const userMachine = createMachine(
   withLoad(
      {
         states: ["idle", "loading", "ready"],
         data: { user: null as User | null },
      },
      { load: getUser, into: (d) => d.user }
   )
)

await userMachine.event.load()
userMachine.data.user // { name: "Ada" }

load is the event the decoration added. into is a slot, a function from the data to the field the decoration writes. The states it uses, loading and ready, it finds by name, and loading: ({ S }) => S.busy points one elsewhere. A slot that finds nothing does nothing, so a machine without a loading state skips that step.

Mistakes are type errors: a required option left out, a field that can't hold what the decoration writes, or a state the machine doesn't have.

@statekit/decoration-tanstack-query works the same way: withLoad with caching, deduplication, cancellation, refetching and paging.

Stacking

Decorations nest, and the innermost is applied first:

const machine = createMachine(
   withLogging(withLoad(config, loadOptions), {})
)

When the config already has an event with the same name as one a decoration adds, both run, the config's first, and the call resolves to the config's result once both have finished. Two decorations on one machine work the same way. A selector with the same name replaces the earlier one.

Writing one

npm i @statekit/decorations

createDecoration takes a name, the options a user passes, and the events and selectors to add. This is withLoad:

import {
   createDecoration,
   fn,
   slot,
} from "@statekit/decorations"

type User = { name: string }

export const withLoad = createDecoration({
   name: "withLoad",
   options: {
      load: fn<() => Promise<User>>(),
      into: slot.data<User | null>(),
      loading: slot.state("loading"),
      success: slot.state("ready", "success"),
   },
   events: {
      load: async ({ options }) => {
         options.loading.enter()
         const user = await options.load()

         options.into.set(user)
         options.loading.exit()
         options.success.enter()
      },
   },
})

Options

OptionWhen decoratingIn the event
fn<F>()RequiredThe function
A plain valueOptional, the value is the defaultThe value
slot.data<T>()Required: (d) => d.user, or (d, id) => d.users[id] for event(id)get(), set(value), path
slot.field<T>(names)Optional: (d) => d.error. Defaults to the first of the names that is a top-level fieldget(), set(value), path
slot.state(names)Optional: ({ S }) => S.done. Defaults to the first of the names that is a stateenter(), exit(), is()

slot.state("ready", "success") uses whichever of the two the machine has.

What events and selectors get

A decoration's events get { options, data, id }, then their arguments. options are the resolved options: functions to call, slots to read and write. data is a copy of the machine's data, taken when it's read, and it stays usable after the event ends. There is no draft, since a decoration writes through its slots. id is the machine's.

Slot paths get the event's arguments, so with into: (d, id) => d.users[id], load(42) writes to d.users[42].

selectors go next to events and get the same. Slots there are for reading, and set, enter and exit throw at runtime.

#javascript, #npm, #react, #readme, #typescript, #web-development

Loading comments...