@alexcarpenter/machine
Tiny state machines for Nano Stores.
Tiny flat state machines for Nano Stores.
- Small. Core is checked by Size Limit and has no runtime dependencies except Nano Stores.
- Nano Stores native. A machine is a store with
get(),listen(),subscribe(), andsend(). - Tree-shakable. Timers, async services, and debugging live in separate entrypoints.
- Flat by design. No actors, nested states, parallel states, or XState compatibility layer in core.
import { machine, state, transition } from "@alexcarpenter/machine";
export const $toggle = machine("off", {
off: state(transition("toggle", "on")),
on: state(transition("toggle", "off")),
});
$toggle.send("toggle");
$toggle.get(); //=> { state: 'on', context: undefined, done: false }
Install
pnpm add nanostores @alexcarpenter/machine
Guide
Context
export const $form = machine(
"idle",
{
idle: state(
transition("submit", "loading", {
guard: ({ context }) => context.valid,
reduce: ({ context, event }) => ({
...context,
submittedAt: event.time,
}),
}),
),
loading: state({ final: true }),
},
{ valid: true },
);
$form.send({ type: "submit", time: Date.now() });
For stronger context and event inference, create typed helpers:
import { setup } from "@alexcarpenter/machine";
interface LoginContext {
username: string;
}
type LoginEvent = { type: "submit"; username: string } | { type: "cancel" };
let { machine, state, transition } = setup<LoginContext, LoginEvent>();
export const $login = machine(
"idle",
{
idle: state(
transition("submit", "submitting", {
guard: ({ event }) => event.username.length > 0,
reduce: ({ context, event }) => ({
...context,
username: event.username,
}),
}),
),
submitting: state(transition("cancel", "idle")),
},
{ username: "" },
);
$login.send({ type: "submit", username: "alex" });
$login.send("cancel");
Lifecycle
const $request = machine("idle", {
idle: state(transition("start", "loading")),
loading: state({
entry: () => console.log("loading"),
exit: () => console.log("done"),
on: [transition("resolve", "success")],
}),
success: state({ final: true }),
});
Lifecycle work is lazy. Entry handlers, delays, and invokes start when the
machine is mounted by listen(), subscribe(), useStore(), or keepMount().
Always
const $session = machine(
"checking",
{
checking: state({
always: transition(null, "signedIn", {
guard: ({ context }) => context.user != null,
}),
}),
signedIn: state(),
signedOut: state(),
},
{
user: { id: "1" },
},
);
Always transitions run when a state is entered. A loop limit protects against infinite transient cycles.
Delays
Timer support is optional:
import { delay } from "@alexcarpenter/machine/delay";
const $toast = machine("visible", {
visible: state({
on: [delay(3000, "hidden")],
}),
hidden: state({ final: true }),
});
Invoke
Promise and callback services are optional:
import { invoke } from "@alexcarpenter/machine/invoke";
const $user = machine(
"loading",
{
loading: state({
entry: invoke({
src: ({ signal }) =>
fetch("/api/user", { signal }).then((r) => r.json()),
done: "ready",
error: "failed",
reduce: ({ context, event }) => ({
...context,
user: event.output,
}),
}),
}),
ready: state({ final: true }),
failed: state(transition("retry", "loading")),
},
{
user: undefined,
},
);
Invoke ignores late promise results after state exit and aborts the provided
AbortSignal on cleanup.
Debug
Transition logging is optional:
import { debug } from "@alexcarpenter/machine/debug";
let stop = debug($user, {
name: "user",
});
Logs:
[user] loading -- done --> ready
Integration
React
Use the normal Nano Stores React binding:
import { useStore } from "@nanostores/react";
import { machine, state, transition } from "@alexcarpenter/machine";
const $toggle = machine("off", {
off: state(transition("toggle", "on")),
on: state(transition("toggle", "off")),
});
export function ToggleButton() {
let toggle = useStore($toggle);
return (
<button onClick={() => $toggle.send("toggle")}>
{toggle.state === "on" ? "On" : "Off"}
</button>
);
}
API
machine(initial, states, context?, opts?)
Creates a Nano Store machine. The store value is:
{
state: string;
context: unknown;
done: boolean;
}
The returned store has one extra method:
$machine.send(event);
Events can be strings or objects with a type string.
setup<Context, Event>()
Returns typed machine, state, and transition helpers. Use it when you want
callback arguments and send() calls to be checked against your context and
event union.
state(...transitions)
Creates a state with event transitions.
state(transition("toggle", "on"));
state(config)
Creates a state with lifecycle options.
state({
entry: [() => {}, invoke({ src, done: "ready" })],
exit: () => {},
on: [transition("retry", "loading")],
always: transition(null, "ready"),
final: true,
});
transition(event, target, opts?)
Creates an event transition. event can be a string or null for always.
transition("submit", "loading", {
guard: ({ context, event }) => true,
reduce: ({ context, event }) => context,
action: ({ context, event }) => {},
});
delay(ms, target, opts?)
Creates a delayed transition from @alexcarpenter/machine/delay.
debug(machine, opts?)
Logs transitions from @alexcarpenter/machine/debug. Options:
{
name?: string
event?: boolean
snapshot?: boolean
logger?: (...args: any[]) => void
}
invoke(opts)
Starts a promise or callback service from @alexcarpenter/machine/invoke.
invoke({
src: ({ context, signal, send }) => Promise.resolve(),
done: "ready",
error: "failed",
});
src can return a promise, nothing, or a cleanup function.
Development Checks
Development builds throw for unknown initial states, unknown transition targets,
invalid event objects, and infinite always loops. Production builds can remove
these checks through process.env.NODE_ENV.