A simple yet powerful finite state machine React hook
@rkrupinski/use-state-machine
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)
History
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)
Installation
npm install @rkrupinski/use-state-machine
Examples
View source code or live.
Examples cover:
- a basic machine with context and guards
- sending events with payload
- http with error recovery
API
State
const [
state, // <--- this guy
send,
] = useStateMachine(/* ... */);
state
is an object of the following shape:
Name | Description |
---|---|
value
|
Type: string
The name of the current state. |
nextEvents
|
Type: string[]
The names of possible events. (see Events) |
event
|
Type: Event
The event that led to the current state. (see Events) |
context
|
Type: C (inferred)
Machine's extended state. Think of it as a place to store additional, machine-related data throughout its whole lifecycle. (see Context) |
Events
const [
state,
send, // <--- this guy
] = useStateMachine(/* ... */);
Once initialized, events can be sent to the machine using the send
function.
Name | Description |
---|---|
send
|
Type: (event: string | Event) => void
|
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)
Machine options
const [state, send] = useStateMachine({
initial: "idle",
states: {
/* ... */
},
context: 42,
});
Name | Description |
---|---|
initial (required)
|
Type: string
The initial machine state value. ℹ️ Note: Must be a key of states
|
states (required)
|
Type: { [key: string]: StateConfig }
An object with configuration for all the states. (see Configuring states) |
context
|
Type: C
Initial context value. ℹ️ Note: Used for inferring context type. (see Context) |
Configuring states
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 | Description |
---|---|
on
|
Type: { [key: string]: string | EvtConfig }
An object with configuration for all the transitions supported by this particular state. (see Configuring state transitions) |
effect
|
Type: Effect
A callback fired once the machine has transitioned to this particular state. (see Effects) |
ℹ️ Note: There can't be a state that's neither initial, nor can be transitioned to.
Effects
You can define a callback to fire once the machine has transitioned to this 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 | Description |
---|---|
context
|
Type: C (inferred)
The current value of the machine context. (see Context) |
setContext
|
Type: (updater: (context: C) => C) => void
A function to update the value of context .
(see Context) |
event
|
Type: Event
The event that led to the current state. (see Events) |
send
|
Type: (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.
Configuring state transitions
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 | Description |
---|---|
target (required)
|
Type: string
Target state value. ℹ️ Note: Must be a key of states .
(see Configuring states) |
guard
|
Type: Guard
A boolean -returning function to determine whether state transition is allowed.
(see Guards) |
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 | Description |
---|---|
event
|
Type: Event
The event that triggered state transition. (see Events) |
context
|
Type: C (inferred)
The current value of the machine context. (see Context) |
Event payload
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;
}
Context
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'