A simple yet powerful finite state machine React hook.
const [state, send] = useStateMachine({
initial: "enabled",
states: {
enabled: {
on: { TOGGLE: "disabled" },
},
disabled: {
on: { TOGGLE: "enabled" },
},
},
});
Comes packed with features like:
- effects (state entry/exit)
- guards (allow/prevent state transitions)
- extended state (context)
- good to very good TypeScript experience (see History)
Table of contents:
This project was born as an attempt to reimplement @cassiozen/usestatemachine in a more "friendly" way. Despite only weighing <1kB, I found the reference project being slightly overly complex, especially on the type system side of things.
ℹ️ Note: This is based on version 1.0.0-beta.4 (source code)
Differences compared to the reference project:
- simpler implementation
- simpler types (with added benefit of making invalid/orphan states impossible)
- manual payload typing/decoding (in place of "schema"; see Event payload for details)
- manual context typing (in place of "schema"; see Context for details)
npm install @rkrupinski/use-state-machine
View source code or live.
Examples cover:
- a basic machine with context and guards
- sending events with payload
- http with error recovery
const [
state, // <--- this guy
send,
] = useStateMachine(/* ... */);
state
is an object of the following shape:
Name | Type | Description |
---|---|---|
value
|
string
|
The name of the current state. |
nextEvents
|
string[]
|
The names of possible events.
(see Events) |
event
|
Event
|
The event that led to the current state.
(see Events) |
context
|
C (inferred)
|
Machine's extended state. Think of it as a place to store additional, machine-related data throughout its whole lifecycle.
(see Context) |
const [
state,
send, // <--- this guy
] = useStateMachine(/* ... */);
Once initialized, events can be sent to the machine using the send
function.
Name | Type | Description |
---|---|---|
send
|
(event: string | Event) => void
|
Sends events to the machine |
When sending events you can either use a shorthand (string
) syntax:
send("START");
or the object (Event
) syntax:
send({ type: "START" });
Under the hood, all sent events are normalized to objects (Event
).
ℹ️ Note: The reason behind having 2 formats is that events, apart from being of certain type
, can also carry payload
.
(see Event payload)
const [state, send] = useStateMachine({
initial: "idle",
states: {
/* ... */
},
context: 42,
});
Machine can be configured with the following options:
Name | Type | Description |
---|---|---|
initial (required)
|
string
|
The initial machine state value.
ℹ️ Note: Must be a key of states
|
states (required)
|
{ [key: string]: StateConfig }
|
An object with configuration for all the states.
(see Configuring states) |
context
|
C (inferred)
|
Initial context value.
(see Context) |
You can configure individual states using the states
field of the machine options.
const [state, send] = useStateMachine({
/* ... */
states: {
idle: {
on: {
START: "running",
},
effect() {
console.log("idling");
},
},
/* ... */
},
});
Keys of the states
object are state names, values are StateConfig
object of the following shape:
Name | Type | Description |
---|---|---|
on
|
{ [key: string]: string | EvtConfig }
|
An object with configuration for all the transitions supported by this particular state.
(see Configuring state transitions) |
effect
|
Effect
|
A callback fired once the machine has transitioned to a particular state.
(see Effects) |
ℹ️ Note: There can't be a state that's neither initial, nor can be transitioned to.
You can define a callback to fire once the machine has transitioned to a particular state using the effect
field.
const [state, send] = useStateMachine({
/* ... */
states: {
idle: {
effect({ context, setContext, event, send }) {
console.log("idling due to", event.type);
return () => {
console.log("idling no more");
};
},
},
/* ... */
},
});
The effect
callback will receive an object of the following shape:
Name | Type | Description |
---|---|---|
context
|
C (inferred)
|
The current value of the machine context.
(see Context) |
setContext
|
(updater: (context: C) => C) => void
|
A function to update the value of context .
(see Context) |
event
|
Event
|
The event that triggered the current machine state.
(see Events) |
send
|
(event: string | Event) => void
|
A function to send events to the machine.
(see Events) |
If the return value from effect
is of type function
, that function will be executed when the machine transitions away from the current state (exit/cleanup effect):
effect() {
console.log('entered a state');
return () => {
console.log('exited a state');
};
},
ℹ️ Note: Events are processed synchronously while effects are asynchronous. In other words, if several events are sent synchronously, e.g.:
send("ONE");
send("TWO");
send("THREE");
state transitions will be performed accordingly, yet only the effect for state triggered by THREE
(if defined) will be executed.
For every state you can configure when and if a transition to a different state should be performed. This is done via the on
property of StateConfig
.
const [state, send] = useStateMachine({
/* ... */
states: {
idle: {
on: {
START: "running",
FUEL_CHECK: {
target: "off",
guard() {
return isOutOfFuel();
},
},
},
},
off: {},
},
});
Transition config can either be a string
(denoting the target state value) or an object of the following shape:
Name | Type | Description |
---|---|---|
target (required)
|
string
|
Target state value.
ℹ️ Note: Must be a key of states .
(see Configuring states) |
guard
|
Guard
|
A boolean -returning function to determine whether state transition is allowed.
(see Guards) |
The purpose of guards is to determine whether state transition is allowed. A guard
function is invoked before performing state transition and depending on its return value:
true
➡️ transition is performedfalse
➡️ transition is prevented
A guard
function will receive an object of the following shape:
Name | Type | Description |
---|---|---|
event
|
Event
|
The event that triggered state transition.
(see Events) |
context
|
C (inferred)
|
The current value of the machine context.
(see Context) |
When using the object (Event
) syntax, you can send events with payload like so:
send({
type: "REFUEL",
payload: { gallons: 5 },
});
The payload can be then consumed from:
How is it typed though? Is the type of payload
inferred correctly?
For several reasons, the most important of which is simplicity (see History), this library does neither aim at inferring, nor allows providing detailed event types. Instead, it encourages using other techniques, like:
- Duck typing
- Type guards
- Decoders
The payload (event.payload
) is always typed as unknown
and it's up to the consumer to extract all the required information from it.
Here's an example of a guard
function that only allows refueling if the number of gallons is at least 5
, using io-ts to decode the payload
:
import * as t from "io-ts";
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/Either';
const RefuelPayload = t.type({
gallons: t.number,
});
/* ... */
guard({ event }) {
const gallons = pipe(
RefuelPayload.decode(event.payload),
fold(
() => 0,
p => p.gallons,
),
);
return gallons >= 5;
}
As mentioned above, the type of context
is inferred from the initial value (see Machine options).
Type inference is straightforward for basic types like:
42
➡️number
'context'
➡️string
[1, 2, 3]
➡️number[]
It gets tricky though if you need more complex constructs like:
- type narrowing (
'foo'
vsstring
) - optionality (
{ foo?: string }
) - unions (
'foo' | 'bar'
)
Again, complex inference and annotating all the things through generic parameters is beyond the scope of this library (see History). What it encourages instead is "hinting" TypeScript on the actual type of context
.
This can be done via type assertions:
type ContextType = "foo" | "bar";
const [state, send] = useStateMachine({
/* ... */
context: "foo" as ContextType,
});
state.context; // 'foo' | 'bar'
- State machines on Wikipedia
- @cassiozen/usestatemachine
- XState