st4t3
v0.0.13
Published
An small-but-powerful typesafe state machine library
Downloads
5
Readme
St4t3
A small-but-powerful typesafe state machine library designed to handle large state graphs with minimal memory usage. It only keeps a single state instance in memory at a time per-machine, and allows you to break large state machines into many files rather than forcing you to define the machine entirely in one file to get full type safety. There are no runtime dependencies and the code is <850 lines of TypeScript, excluding comments.
- Development
- Getting started
- Injecting props
- Following events from other libraries
- Events
- Nested state machines
- Middleware
- Manually transitioning states from the machine
- Type safety
- Performance
- Comparison to alternatives
Development
Build with npm run build
. Test with npm run test
. Check the test coverage
report with npm run coverage
.
Getting started
Every state machine is composed of three parts: a set of messages, a set of states, and the machine that runs the states.
To define the messages, define a type or interface with some functions:
export type Messages = {
jump(): void,
land(): void,
};
The total set of messages exists purely in the type system; it has no runtime representation or cost. Messages sent at runtime are just the string name of the message, alongside any arguments the function takes; the TypeScript compiler will typecheck them for you just like it typechecks ordinary function calls.
States can listen to these messages and act on them:
import * as create from "st4t3";
import { Messages } from "./messages";
// You must pass the names of any states you plan on transitioning to in the
// state definition and any messages you listen to, like so:
export const Jump = create.transition<"Land", Pick<Messages, "land">>().build(state => {
console.log("jumped!");
return state.build({
messages: goto => state.msg({
// Since we declared we listen to the "land" message, we can (and must)
// define a message handler of the same name here
land() {
// Since you declared you can transition to the "Land" state, you can call
// that here. The TypeScript compiler ensures you can only `goto`
// states you've defined in the `transition` function above
goto("Land");
},
}),
// Stop runs whenever you're leaving a state
stop() {
console.log("stopping jumping");
},
});
});
Now let's look at what a Land
state might look like:
import * as create from "st4t3";
import { Messages } from "./messages";
export const Land = create.transition<"Jump", Pick<Messages, "jump">>().build(state => {
console.log("landed");
return state.build({
messages: goto => state.msg({
jump() {
goto("Jump");
}
}),
});
});
Now that we have our two states, Jump
and Land
, transitioning between each
other, let's wire them up in a state machine so they can run:
import * as create from "st4t3";
import { Messages } from "./messages";
import { Jump } from "./jump";
import { Land } from "./land";
// Pass in the initial state name, as well as the state classes themselves:
const machine = create.machine<Messages>().build({
initial: "Land",
// Note that this is an object, not an array! Object shorthand syntax allows
// you to leave out the keys if the values are already named the same as the
// keys. We use the key names to help with typechecking, so that's why this
// must be written in object shorthand syntax rather than as an array
states: {
Jump, Land
}
});
machine.start({}); // Prints "landed."
machine.dispatch("jump"); // Prints "jumped!"
machine.dispatch("jump"); // No-op, since the jump state ignores further jump messages
machine.dispatch("land"); // Prints "stopping jumping" and then "landed."
What if I want a state that never transitions?
import * as create from "st4t3";
// Short form:
export const Final = create.transition().build();
// Longer form:
export const LongerFormFinal = create.transition<never>().build(state => {
return state.build();
});
// Longest form:
export const LongestFormFinal = create.transition<never>().build(state => {
return state.build({
messages: () => state.msg({}),
});
});
Message parameters
Sometimes you may want to pass data along with your messages; for example, if
movement is being controlled by an analog stick, you'd want to know how much
the stick is being tilted. The message type can have functions define
parameters, and then the dispatch
function will require you to pass them in
for those messages; e.g.:
type Messages = {
jump(): void,
move(x: number, y: number),
};
const machine = create.machine<Messages>().build({
// ...
});
machine.start({});
machine.dispatch("move", 0.5, 0.2); // Compiler checks you pass x, y here
machine.dispatch("jump"); // Compiler checks you pass nothing here
Injecting props
Sometimes, you may want your set of states to accept some sort of configuration
data, or to be able to pass some kind of top-level data from your program into
the states so they can take action on it — similar to React's props
.
States can optionally define data they require, and at Machine#start()
time
you'll need to provide it. In the state class, you can access the data by
reading this.props
. For example:
export type Messages = {
jump(): void,
land(): void,
};
export type Props = {
jumpPower: number,
bounceOnLand: boolean,
};
type JumpProps = Pick<Props, "jumpPower">;
type JumpMessages = Pick<Messages, "land">;
const Jump = create.transition<"Land", JumpMessages, JumpProps>().build(state => {
console.log(`Jumped with power ${state.props.jumpPower}`);
return state.build({
messages: goto => state.msg({
land() { goto("Land") }
})
});
});
type LandProps = Pick<Props, "bounceOnLand">;
type LandMessages = Pick<Messages, "jump">;
const Land = create.transition<"Jump", LandMessages, LandProps>().build(state => {
if(this.props.bounceOnLand) console.log("Bouncy land");
else console.log("Unbouncy land");
return state.build({
messages: goto => state.msg({
jump() { goto("Jump"); }
})
});
});
const machine = create.machine<Messages, Props>().build({
initial: "Land",
states: {
Jump, Land,
},
});
// You have to pass in all of the data required here. The type system checks
// that all specified data is actually passed in.
machine.start({
bounceOnLand: false,
jumpPower: 5.6,
}); // Prints "Unbouncy land"
machine.jump(); // Prints "Jumped with power 5.6"
Props remain the same from the initial start()
call through all goto()
calls — you don't need to pass props into transitions. You can think of
props being valid through a single run of a state machine; you only need to
reset them when you call stop()
and then a new invocation of start()
.
That being said, although you're not required to propagate them yourself
through each goto
call, you may update them on state transitions via goto
if you want to. For example:
type Direction ='north' | 'south' | 'east' | 'west';
type Props = {
direction: Direction;
};
type Messages = {
move(dir: Direction): void,
still(): void,
};
const Still = create.transition<'Move', Pick<Messages, 'move'>, Props>().build(state => {
playAnim(`stand-${state.props.direction}`);
return state.build({
messages: goto => state.msg({
move(direction) {
goto('Move', { direction });
},
}),
});
});
const Move = create.transition<'Still' | 'Move', Messages, Props>().build(state => {
playAnim(`walk-${state.props.direction}`);
return state.build({
still() {
goto('Still');
},
move(direction) {
if(direction === state.props.direction) return;
goto('Move', { direction });
},
});
});
Static props
You can also have static props that are used for every invocation of the
machine, and aren't passed into and overwritten on every start
call; instead
of passing them in at start()
time, you instead pass them in when
constructing the machine, like so:
const machine = create.machine<Messages, Props>().build({
initial: "Land",
states: {
Jump, Land,
},
props: {
jumpPower: 5.6,
},
});
// Since you already specified `jumpPower` in the machine constructor, you only
// pass in `bounceOnLand` here. This is enforced by the type system.
machine.start({
bounceOnLand: false,
}); // Prints "Unbouncy land"
machine.jump(); // Prints "Jumped with power 5.6"
Note that both static props and regular props can be updated via goto
; the
only difference is that static props don't need to be passed into
machine.start
. This can be useful for nesting machines, where the inner state
machine has extra props that the outer one doesn't need to be aware of.
Updating props inside message handlers
As shown above, you can update props upon transition via goto(state, props)
.
If you want to update props inside message handlers without triggering a
state change, you can do that too:
type Messages = {
update(msg: string): void,
};
type Props = {
msg: string,
};
const State = create.transition<"Next", Messages, Props>().build(state => {
return state.build({
// The second parameter of the messages function is a `set` function. It
// allows you to update any of the props of your state, similar to `goto`
// but without causing a state transition. All it does is update props: it
// doesn't re-run any initialization code.
// If you want to re-run your initialization code, just use `goto` and
// transition to yourself!
messages: (goto, set) => state.msg({
update(msg) {
set({ msg });
},
}),
});
});
Following events from other libraries
It's pretty common to want to make state transitions based on outside events, either from other libraries (Node file watching?) or the DOM. You might try to do something like this:
type Messages = {
next(): void;
};
const State = create.transition<"Next", Messages>().build(state => {
const buffer = [];
emitter.on("event", (data) => {
if(data === "next") {
state.dispatch("next");
return;
}
buffer.push(data);
});
return state.build({
messages: goto => state.msg({
next() {
goto("Next");
}
}),
});
});
But... there's potentially a subtle problem here. Let's assume emitter
is
going to keep emitting more data, even after the "next"
message. We haven't
deregistered from it, so as more data comes in, State
will keep buffering it,
causing a memory leak! And worse, if it sees the special "next"
string a
second time, it'll cause a state transition again, even if it's not the current
state!
One way of handling this would be to manually deregister every event handler on
the stop
function. But that's pretty error-prone, since you can easily add a
handler and forget to deregister it. St4t3 provides a helpful follow
API that
wraps Node-style EventEmitters, or DOM-style
addEventListener
/removeEventListener
objects, and will automatically
deregister any event handler passed through the follow
API when the state
stops or is transitioned away from. A fixed version of the previous example
would look like:
type Messages = {
next(): void,
};
const State = create.transition<"Next", Messages>().build(state => {
const buffer = [];
state.follow.on(emitter, "event", (data) => {
if(data === "next") {
state.dispatch("next");
return;
}
buffer.push(data);
});
return state.build({
messages: goto => state.msg({
next() {
goto("Next");
},
}),
});
});
The follow.on
call works with Node-style EventEmitters, and objects with
addEventListener
/removeEventListener
functions — there's no
follow.addEventListener
, because follow.on
will transparently use that if
on
/off
don't exist. It's also typesafe, and if your EventEmitter is typed
to only handle certain events, or takes callbacks of different types depending
on the event, the follow
API will mirror whatever types passed-in emitter
accepts or rejects.
The follow
object supports the full range of on
, off
, and once
calls,
just like ordinary Node-style EventEmitters.
Events
States emit events when they start and stop, and you can listen to them via a
slimmed-down version of the NodeJS EventEmitter API. (And yes, that means you
can listen to other machines with the state.follow
API!) All state
EventEmitters are accessible from machine.events('StateName')
; for example,
to register for the Jump
state's start
event, you'd do the following:
machine.events("Jump").on("start", (props: JumpProps) => {
// ...
});
// Or, since type inference works on callbacks, you can leave out the type:
machine.events("Jump").on("start", (props) => {
// ...
});
The state names passed in as strings are type-checked to ensure that you're actually referring to a real state that exists in the state machine you defined, and didn't typo "Jupm" instead of "Jump."
All events generated by state machines take the props as the first argument to
the callback (although you can of course leave it out if you don't need it).
However, the EventEmitter API is fairly generic, if you want to import it and
use it for your own purposes; it takes a single type parameter defining the
mapping of event names to callback data. For example, to define your own event
emitters that have update
and render
events that provide Physics
and
Graphics
data to callbacks, you'd do:
type EventMapping = {
update: Physics,
render: Graphics,
};
const emitter = new EventEmitter<EventMapping>();
// Example listeners:
emitter.on("update", (physics) => {
});
emitter.on("render", (graphics) => {
});
// Example emit calls:
emitter.emit("update", somePhysicsObject);
emitter.emit("render", someGraphicsObject);
EventEmitter API
on('start' | 'stop', callback)
Runs the callback every time either start
or stop
is called. For example:
machine.events("Land").on("start", (props) => {
// ...
});
off('start' | 'stop', callback)
Removes the callback from being registered to listen to either the start
or
stop
event. Returns true
if the callback was previously registered and thus
removed; returns false
otherwise, indicating the callback was never
registered in the first place. For example:
machine.events("Land").off("start", callback);
once('start' | 'stop', callback)
Runs the callback the first time either start
or stop
is called, and then
removes it from the listener list. For example:
machine.events("Land").once("start", (props) => {
// ...
});
clear()
Removes all listeners for all events, effectively resetting the EventEmitter. For example:
machine.events("Land").clear();
It's rare you'd want to do this for state machines, but may be useful if you're using this as a generic EventEmitter class.
Nested state machines
The St4t3 library has built-in support for nested (also called "hierarchical")
state machines, using the children
property. State machines nested
inside states will automatically be started when the parent state starts, and
stopped when the parent stops, and will have the parent's props passed to it at
start time. All messages dispatched to the parent will also be forwarded to the
child.
Child state machines are created with state.child<Messages, Props>()
rather
than create.machine<Messages, Props>()
, in order to track type information
about parent states. The type system enforces that you create the child state
machines this way; it's impossible to accidentally forget and use the top-level
machine builder instead of state.child
.
For example:
import * as create from "st4t3";
type Messages = {
jump(): void,
land(): void,
};
const InitialJump = create.transition<"DoubleJump", Pick<Messages, "jump">>().build(state => {
console.log("initial jump");
return state.build({
messages: goto => state.msg({
jump() {
goto("DoubleJump");
}
}),
});
});
const DoubleJump = create.transition().build(state => {
console.log("double jump");
// Triple jumps are not allowed, so just ignore all messages
return state.build({});
});
const Jump = create.transition<"Land", Pick<Messages, "land">>().build(state => {
return state.build({
children: {
jumpMachine: state.child<Messages>().build({
initial: "InitialJump",
states: { InitialJump, DoubleJump },
}),
},
messages: goto => state.msg({
land() { goto("Land"); },
}),
});
});
const Land = create.transition<"Jump", Pick<Messages, "jump">>().build(state => {
return state.build({
messages: goto => state.msg({
jump() {
goto("Jump");
},
})
});
});
const machine = create.machine<Messages>().build({
initial: "Land",
states: { Land, Jump },
});
// Runs the function to create the Land state
machine.start({});
// Land#stop is called, and Jump#start and InitialJump#start are then called:
machine.dispatch("jump");
// InitialJump#stop is called, then DoubleJump#start is called:
machine.dispatch("jump");
// Jump#stop and DoubleJump#stop are called, then Land#start is called:
machine.dispatch("land");
Nested state machines, like all state machines, don't need to all be defined in the same file; it's completely valid to break apart the states into separate files.
A note about child prop types
Since child machines have their machine.start({ ... })
functions called with
the parent's props, there are some restrictions on what the child machine's
props must be:
- Child machines must accept all of the parent's props. Otherwise, the
machine.start({ ... })
call would be invalid, since it would be passing in props that thestart
function doesn't ordinarily accept. - Child machines can't declare parent props as static, since static props
can't be passed into
machine.start({ ... })
— and parents will pass all of their props into the child machinesstart
method. - If child machines define extra props unknown to the parent, they must be
declared as static props, since the parent won't know to pass those unknown
props to the child's
start
method.
These restrictions are checked by the compiler, so it's impossible to accidentally have these kinds of bugs. Note that this is only applicable to child machines — the child states themselves can ignore props they don't use, like any other kind of state.
These restrictions are in fact the primary reason for the design of static
props: without static props — that is, if all props had to be passed into
machine.start({ ... })
— it would be impossible to have child states
with differing props from their parents, since the parents couldn't pass in
data they didn't know existed to the child machines.
Subscribing to nested events:
The events API also supports subscribing to nested state machine events with
full type safety, by using the chainable child(machineName)
method:
machine
.events("Jump")
.child("jumpMachine")
.events("InitialJump")
.on("start", (props) => {
});
Much like the ordinary events
API, the nested machine names passed in as
strings are type-checked by the TypeScript compiler to ensure that they refer
to real, nested state machines that you've actually defined on the given
states. You can continue chaining these calls to arbitrarily-deeply-nested
machines; for an example taken directly from our test suite:
const MostInner = create.transition().build(s => s.build());
const Inner = create.transition().build(s => s.build({
children: {
child: s.child().build({
initial: "MostInner",
states: { MostInner },
}),
},
messages: () => s.msg({}),
}));
const Outer = create.transition().build(s => s.build({
children: {
child: s.child().build({
initial: "Inner",
states: { Inner },
}),
},
messages: () => s.msg({}),
}));
const MostOuter = create.transition().build(s => s.build({
children: {
child: s.child().build({
initial: "Outer",
states: { Outer },
}),
},
messages: () => s.msg({}),
}));
const machine = create.machine().build({
initial: "MostOuter",
states: { MostOuter },
});
const mock = machine
.events("MostOuter")
.child("child")
.events("Outer")
.child("child")
.events("Inner")
.child("child")
.events("MostInner")
.on("start", vi.fn());
machine.start({});
Dispatching to a parent
Children can opt-in to a special parent
variable getting passed in at
construction time, allowing them to dispatch messages back to their parent. All
they need to do is specify what messages they expect to be able to send to
their parent; for example:
const DoubleJump = create.transition<
never,
DoubleJumpMessages,
Props,
ParentMessages
>().build((state, parent) => state.build({
messages: () => state.msg({
someMessage() {
parent.dispatch("someParentMessage");
},
}),
});
Dispatching to a parent is equivalent to dispatching to the parent's machine; the parent will get the message, and it will be forwarded to all children as well.
Middleware
Middleware allows you to extract commonly-used message handling and reuse it across many states. Middleware objects are just states, constructed just like any other state; for example:
type Props = {
msg: string;
};
type Messages = {
print(): void;
};
const Middleware = create.transition<never, Messages, Props>().build(state => state.build({
messages: () => state.msg({
print() {
console.log(state.props.msg);
}
}),
}));
// It's okay to not define `print` here, since we're using middleware that defines it for us
const State = create.transition<never, Messages, Props>().middleware({ Middleware }).build();
const machine = create.machine<Messages, Props>().build({
initial: "State",
// You don't need to pass Middleware in here, since it's only being used as
// middleware and can't be independently transitioned to
states: { State },
});
Note that, like machine creation, the .middleware
function takes an object
literal rather than an array. Although object ordering is only recently-defined
in the spec, in practice every
major browser maintained consistent insertion-order traversal for years prior
to this spec change, as long as you didn't use exotic host objects, modify the
prototype of an object mid-traversal, use the magic Proxy
objects, or a few
other arcane things. Just use regular hashes for this and you'll be fine!
Pretend it's an array.
Middleware can also do state transitions, just like ordinary states. If middleware causes a state transition, it will prevent the rest of the chain from running; e.g.:
type Messages = {
next(): void,
};
type Props = {
skip: boolean,
};
const Middleware = create.transition<"Next", Messages, Props>().build(state => state.build({
messages: goto => state.msg({
next() {
if(state.props.skip) {
console.log("Skipped");
goto("Next");
}
},
}),
}));
const Initial = create.transition<
"Next", Messages, Props
>().middleware({ Middleware }).build(state => state.build({
messages: goto => state.msg({
console.log("Not skipped");
goto("Next");
}),
});
const Next = create.transition().build();
const machine = create.machine({
initial: "Initial",
states: { Initial, Next },
});
machine.start({
skip: false,
});
machine.dispatch("next"); // Prints "Not skipped" and transitions to next
machine.stop();
machine.start({
skip: true,
});
machine.dispatch("next"); // Prints "Skipped" and transitions to next
Middleware type contract
Middleware needs to fulfill a specific type contract, which is checked by the TypeScript compiler:
- If it transitions to another state via
goto(...)
, its specifications for which states it transitions to must be a subset or equal to the states that the calling state transitions to. To put it another way: if the middleware was declared withcreate.transition<"Next">()
, the state using that middleware must at least also transition to"Next"
(it can also transition to other states the middleware is unaware of;"Next" | "Final"
is fine). - Middleware can respond to either a superset of or a subset of messages that the calling state responds to (or exactly the same set of messages). If there's no overlap, it'll be an error.
- If the middleware uses props, they must be a subset of (or equal to) the props that the calling state uses.
State type changes using middleware
When states use middleware, any messages the middleware responds to become
optional in the state using the middleware. For example: if your middleware
defines a message handler for print
, you don't need to define your own
message handler for print
, even if your create.transition
call says you
respond to print
(ordinarily, saying that you respond to print
but not
defining a message handler for it is an error). This is meant as a convenience
to allow you to refactor commonly-used message handlers out into shared
middleware, and not force callers to then define empty, dummy method handlers
just to fulfill a type contract.
Middleware states and machine creation
If you use a state solely as middleware, you don't need to pass it into the
machine's states
hash: it'll get automatically included since you already
called .middleware({ ... })
on it. If you use a state as both middleware
and an independent state you can transition to, you do need to tell the
machine about it by passing it into the states
hash.
Adding props from middleware
Sometimes, middleware may create or read data that it wants to pass on to any
states that use it; for example, a piece of middleware might read user data and
make it available to states. You can do this by adding a props
key to the
hash given to state.build({ ... })
; for example:
const UserMiddleware = create.transition().build(state => {
const user = store.getUser();
return state.build({
messages: () => state.msg({}),
props: {
user,
},
});
});
const middleware = { UserMiddleware };
type Props = {
user: User
};
// You could also write the above as: type Props = create.MiddlewareProps<typeof middleware>;
const State = create.transition<
"Next",
{},
Props
>().middleware(middleware).build(state => {
console.log(state.props.user);
return state.build();
});
If you specify props in middleware, you won't need to pass those props into the machine, since the middleware is already handling them; for example, a machine for the state above would look like:
// You don't need to specify the user prop, since the middleware handles it
const machine = create.machine().build({
initial: "State",
states: { State },
});
// Similarly, you don't need to pass it in here:
machine.start({});
Middleware props can be anything, as long as they don't conflict with other
props you're using for the state. If your state's props specify msg: string
,
your middleware can't return { msg: 5 }
: that's a type error.
Updating returned props from middleware
For machine-wide props, you can use goto
or set
to update the props. But
since middleware props are scoped to just the props returned by that
middleware, those will result in type errors if you try to update your own
returned props from within a middleware; for example:
type Messages = {
tick(): void,
};
type Props = {
allowDoubleJumps: boolean,
};
const Middleware = create.transition<never, Messages, Props>().build(state => {
let value = 0;
return state.build({
props: { value },
messages: (_, set) => state.msg({
tick() {
value++;
// The following is a type error, since our Props are { allowDoubleJumps: boolean }!
set({ value });
},
}),
});
});
To update returned props from a middleware, you can use the forward
function:
const Middleware = create.transition<never, Messages, Props>().build(state => {
let value = 0;
return state.build({
props: { value },
messages: (_1, _2, forward) => state.msg({
tick() {
value++;
forward({ value });
},
}),
});
});
The forward
function will update the props you set in the props
field, and
will also notify any client states of the middleware that the props changed,
and cause them to update their copy of the props.
Manually transitioning states from the machine
You can manually attempt state transitions on the state machine itself; for example:
machine.goto('Land');
This works identically to goto
, except that by default it will ignore the
call if you're already in the specified state; e.g. if you're currently in
state Land
, calling machine.goto('Land')
is a no-op. If you want to force
it to rerun the Land
initialization, use machine.force('Land')
.
States themselves don't have this restriction: if you want to transition to yourself, you may, as long as you declare that transition when you create the state, e.g.
const Land = create.transition<'Land' | 'Jump', /* ... */>().build(state => {
return state.build({
messages: goto => state.msg({
someMessage() {
goto('Land');
}
}),
});
});
This difference is purely for developer experience: typically when you call
machine.goto
, what you mean to do is to ensure the machine is in that state;
you aren't necessarily trying to re-run that state if it's already there.
Whereas the only use case for calling goto('YOUR_OWN_NAME')
is to re-run
initialization code; if you didn't mean to do that, you could instead simply do
nothing (since you know you're already in your own state).
Type safety
- When you create a
machine
, it checks for exhaustiveness at compile time: you can't accidentally forget to include a state that one of your other states needs to transition to. - When you create a
machine
, it also checks to make sure theProps
type you've given it matches the props expected by the states. If a state requires a property, you can't accidentally forget to include it in the machine props. - When you create a
child
, it makes sure that the parent state actually responds to all of the messages the child states have requested to be able to send to the parent. You can't create children that will dispatch events to you that you don't know about. - That being said, children are allowed to have their own events you don't know about, as long as they aren't dispatching them back to you; they might use those events to privately communicate to their own children, for example. Similarly, your parent is allowed to have events you don't know about. The only assertion is that if a child declares it will send you a message, you must be aware of that message.
- A state can only transition to the states it names in its class definition. As a result, you have to use string literals — the compiler can't analyze dynamic strings passed in at runtime. That being said, this restriction also helps human maintainers understand which states transition to which other states... And helping human maintainers understand your state graph is probably a big part of why you're using a state machine.
Performance
St4t3 allocates the state objects on-demand, when you call start
or goto
.
It only keeps the current state in memory (or no states in memory, prior to the
first start
call).
// Jump and Land are not allocated yet
const machine = create.machine<Messages, Props>().build({
initial: "Land",
states: { Jump, Land }
});
// Land is allocated here:
machine.start({});
// The Land instance is overwritten by a new Jump allocation here:
machine.goto("Jump");
All callbacks registered through the .events
API are kept in memory for the
lifetime of the state machine, though, since the state machine needs to keep
track of which ones exist in order to call them when it instantiates or gets
rid of states.
The state instances are not long-lived: the Machine
class will regenerate
them every time they're transitioned to. This means that memory usage is
minimal, but at the cost of increased runtime allocations. Ideally don't do
lots of transitions inside tight loops.
Unlike some other libraries, there's no special registry of machines: this means you don't need to worry about machine memory leaks, since they get garbage collected like every other JS object.
Comparison to alternatives
XState
XState is the 800lb gorilla in the room of JS/TS state machine libraries.
Although XState and St4t3 have similar goals in terms of making stateful code
more understandable and reducing explicit branching, they implement different
programming models: XState allows modeling finite state machines and
statecharts, whereas St4t3 is similar to a "transition system" (also called an
"infinite state machine"). Finite state machines have lexical power equivalent
to a regex; I'm not familiar with formalized lexical power of statecharts, but
since they're largely just hierarchical finite state machines, they in practice
don't seem to be particularly more expressive — although allowing
hierarchical machines is at least much more convenient than flat ones. On the
other hand, St4t3 is Turing-complete. (To convince myself that this is true, I
modeled a Brainfuck interpreter in St4t3, found in examples/brainfuck.ts
.)
For states that can be modeled by a finite state machine, XState allows excellent tooling; it provides, for example, visualizations of every state in the system, the inputs, and the state transitions caused by any input. However, for complex systems, you may need to model many, many states and/or inputs, and in some cases it may not be possible to model your domain in XState. If you can't parse it with a regex, you can't model it with a finite state machine, and modeling it with a statechart will either be difficult or perhaps impossible.
On the other hand, since St4t3 is Turing-complete, tooling is by definition
more limited, since making strong guarantees about whether certain code will
run or halt given various inputs is NP-complete. However, you'll be able to
model just about anything a programming language can represent, and it will
often be more concise than doing so in XState. Because XState doesn't use
ordinary TypeScript to determine things like whether or not an input should
result in a state transition, it needs to invent its own sub-language for e.g.
branching, using a variety of "guard" types expressed as JSON instead of an if
statement. St4t3 just uses if
statements, or whatever other TypeScript code
you'd want to use.
XState is also much larger and more complex than St4t3; the "core" implementation (the feature-complete version, but without counting any of the external tooling) is roughly 7x the code count. It's hefty.
TS-FSM
TSM-FSM is a lovely little finite state machine library whose Events
system
inspired St4t3's message dispatch system. It's extremely small, and if you know
you want a finite state machine, it looks like a nice one.
The same lexical power caveats apply, but even more strongly, since TS-FSM is strictly capable of modeling finite state machines and not statecharts. If you can't parse it with a regex, you can't model it with TS-FSM; and for complex state graphs it will become increasingly cumbersome, since it doesn't support nested state machines.