The tiny state machine hook for React
English (MT) | 日本語
use-machine-ts is a tiny hook for designing state machines in React. It follows the familiar idiomatic React patterns, making it easy to manage state transitions.
// 973 B
import { useMachine } from "use-machine-ts"
// 1.37 KB
import * from "use-machine-ts"
// 1 KB
import * from "use-machine-ts/standard"
// 1.04 KB
import * from "use-machine-ts/shared"
// 1.08 KB
import * from "use-machine-ts/synced"
// 11.12 KB
import { createMachine } from "xstate@5.9.1"
import { useMachine } from "@xstate/react@4.1.0"
use-machine-ts is inspired by @cassiozen/usestatemachine.
t
for defining context and event types is no longer needed.
Instead, you can define schema types using {} as <types...>
.useMachine
, two additional convenient hooks are provided:
useSharedMachine
:
Allows sharing state between multiple React components.
You can also manage state transitions from outside React components.useSyncedMachine
:
Re-rendering is not triggered when the state transitions.
This hook provides a function that returns a snapshot of the state rather than the current state.useMachine
, there's an increase of about 400 bytes (+60%).useMachine
:
Essentially a wrapper around useState
and useEffect
.
Manages state transitions in the same way as useState
.useSharedMachine
:
Essentially a wrapper around useSyncExternalStore
and useEffect
.
Allows sharing state between multiple React components.
You can also manage state transitions from outside React components.useSyncedMachine
:
Similar to useMachine
, but re-rendering is not triggered every time the state transitions.
This hook provides a function that returns a snapshot of the state rather than the current state.createMachine
:
Creates a state machine. Useful for reusing state machine definitions across different components.
Can be used with useMachine
and useSyncedMachine
.createSharedMachine
:
Similar to createMachine
, but can only be used with useSharedMachine
.To install the latest stable version:
npm install use-machine-ts
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
initial: "inactive",
states: {
inactive: {
on: {
TOGGLE: {
target: "active",
guard: "isReady",
},
},
},
active: {
on: { TOGGLE: "inactive" },
effect: "onActive",
},
},
},
{
guards: {
isReady: () => true,
},
effects: {
onActive: () => {
console.log("Now in the 'active' state!")
return () => {
console.log("Now in the 'inactive' state!")
}
},
},
},
)
console.log(state)
// { value: "inactive", context: undefined,
// event: { type: "$init" }, nextEvents: ["TOGGLE"] }
send("TOGGLE")
// Logs: Now in the 'active' state!
console.log(state)
// { value: "active", context: undefined,
// event: { type: "TOGGLE" }, nextEvents: ["TOGGLE"] }
To create an ad-hoc state machine:
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
/* State Machine Definition */,
/* State Machine Configuration (Optional) */,
)
To use a pre-built state machine:
import { useMachine, createMachine } from "use-machine-ts"
const machine = /* @__PURE__ */ createMachine(
/* State Machine Definition */,
/* State Machine Configuration (Optional) */,
)
const [state, send] = useMachine(machine)
Or use the constructor:
import { useMachine, createMachine } from "use-machine-ts"
function machine() {
return createMachine(
/* State Machine Definition */,
/* State Machine Configuration (Optional) */,
)
}
const [state, send] = useMachine(machine)
state
consists of four properties: value
, event
, nextEvents
, and context
.
Property | Type | Description |
---|---|---|
value |
string |
The current state, such as "inactive" or "active" . |
event |
object |
The last event that was sent, causing the current state. For example, { type: "TOGGLE" } . Initially, it is { type: "$init" } . |
nextEvents |
string[] |
A list of events that can be sent in the current state, such as ["TOGGLE"] . |
context |
any |
The extended state of the state machine. See Extended State. |
The send
function is used to send events to the state machine. It takes a single argument, which can be a string representing the event type (e.g., "TOGGLE"
) or an object (e.g., { type: "TOGGLE" }
).
If the current state accepts the event and a transition is possible (see Guards), the state machine's state will be updated, and any associated effects will be executed (see Effects).
You can send additional data using the object format for events (e.g., { type: "TOGGLE", value: 10 }
). For information on defining event types, see Schema.
Property | Type | Required | Description |
---|---|---|---|
initial |
string |
✅ | Defines the initial state of the state machine. |
states |
object |
✅ | Defines the finite states the state machine can be in. (See: Defining States) |
on |
object |
Defines transitions for events not accepted in the current state. (See: Defining States) | |
context |
any |
Defines the extended state of the state machine. (See: Extended State) | |
$schema |
object |
Defines the schema of the state machine by type. (See: Schema) |
Property | Type | Description |
---|---|---|
guards |
object |
Defines guard functions for the state machine. (See: Using Guards) |
effects |
object |
Defines effect functions for the state machine. (See: Using Effects) |
verbose |
boolean 0 1 2 |
Enables debug logging. (See: Logging) |
console |
object |
Defines a custom console for logging output. (See: Logging) |
A state machine can only be in one of a finite number of states at any given time. Additionally, states can only change in response to events.
States are defined as keys in the states
object, and event types are defined as keys in the on
object within each state.
{
states: {
// state name: state object
inactive: {
on: { // event definition
TOGGLE: "active", // Event type: Destination state value
},
},
active: {
on: {
TOGGLE: "inactive",
},
},
},
}
In event definitions, you can use objects with a target
property to control state transitions in more detail (such as adding guards).
{
on: {
TOGGLE: {
target: "active",
guard: "isReady",
},
},
}
Guards are functions that execute before a state transition occurs. If a guard returns true
, the state transition is allowed. If a guard returns false
, the state transition is denied.
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
initial: "inactive",
states: {
inactive: {
on: {
TOGGLE: {
target: "active",
guard: "isReady",
},
},
},
active: {
on: { TOGGLE: "inactive" },
},
},
},
{
guards: {
isReady: () => true,
},
},
)
use-machine-ts provides three helper functions: and
, or
, and not
. You can use these functions to create complex guards.
import { and, not, or, useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
initial: "inactive",
states: {
inactive: {
on: {
TOGGLE: {
target: "active",
guard: and(or("isReady", "isStopped"), not("isDestroyed")),
},
},
},
active: {
on: { TOGGLE: "inactive" },
},
},
},
{
guards: {
isReady: () => true,
isStopped: () => true,
isDestroyed: () => true,
},
},
)
The and
function can be replaced with a simple array.
and(or("isReady", "isStopped"), not("isDestroyed"))
// equals
[or("isReady", "isStopped"), not("isDestroyed")]
If a guard ultimately returns false
, the following log will be output:
Transition from 'inactive' to 'active' denied by guard.
((isReady || isStopped) && !isDestroyed)
^^^^^^^^^^^^
Event { type: "TOGGLE" }
Context undefined
The ^
indicates the guard that caused the state transition to be denied. In the example above, isDestroyed
returning true
caused the state transition to be denied.
[!IMPORTANT]
and
without any guards always returnstrue
.or
without any guards always returnsfalse
.
Effects are functions that execute when the state machine enters a specific state. If the effect returns a function, that function is executed when leaving that state. This behavior is similar to the useEffect
hook in React.
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
initial: "active",
states: {
active: {
on: { TOGGLE: "inactive" },
effect: "onActive",
},
},
},
{
effects: {
onActive: entryParams => {
console.log("Entered 'active' state!")
return exitParams => {
console.log("Left from 'active' state!")
}
},
},
},
)
The effect
property can accept an array instead of a string.
{
effect: [
"onActive",
"onTransition",
],
}
Effect functions receive an object (entryParams
) with the following four properties as a parameter:
Property | Type | Description |
---|---|---|
event |
object |
The event that triggered the transition to the current state. The event is always in object format (e.g., { type: "TOGGLE" } ). |
context |
any |
The extended state of the state machine. |
send |
function |
A function to send events to the state machine. |
setContext |
function |
A function to update the extended state of the state machine. It returns an object with the send property, allowing you to update the context and transition states in one line. |
isMounted |
function |
A function to check if the component is mounted. |
The function returned by the effect function receives an object (exitParams
) with the following four properties as a parameter:
Property | Type | Description |
---|---|---|
event |
object |
The event that triggered the transition from the current state. The event is always in object format (e.g., { type: "TOGGLE" } ). |
context |
any |
The extended state of the state machine. |
send |
function |
A function to send events to the state machine. |
setContext |
function |
A function to update the extended state of the state machine. It returns an object with the send property, allowing you to update the context and transition states in one line. |
isMounted |
function |
A function to check if the component is mounted. |
In the following example, the retryCount
is updated every time the state changes to failure
, and if the limit is reached, it transitions to an error state.
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
initial: "loading",
context: { retryCount: 0 },
states: {
loading: {
on: {
FAILURE: "failure",
DONE: "done",
},
},
failure: {
on: {
RETRY: "loading",
ERROR: "error",
},
effect: "onFailure",
},
error: {
on: {
RETRY: "loading",
},
effect: [
"onError",
"resetRetryCount",
],
},
done: {},
},
},
{
effects: {
onFailure: ({ context, send, setContext }) => {
if (context.retryCount < 3) {
setContext(ctx => ({ retryCount: ctx.retryCount + 1 })).send("RETRY")
} else {
send("ERROR")
}
return ({ event }) => {
if (event.type === "RETRY") {
console.log("Retrying...")
} else {
console.log("The number of retries has reached the upper limit!")
}
}
},
onError: () => {
console.log("Error state entered!")
},
resetRetryCount: ({ setContext }) => {
setContext(() => ({ retryCount: 0 }))
},
},
},
)
[!WARNING] The state machine's definition and configuration cannot be changed midway. Functions defined in
effects
andguards
will continue to reference the values they had when initially defined. Therefore, caution is needed when directly observing state changes.
Here is an example of how to use the React useEffect
hook to update the component's state when the state machine's state changes. This works correctly.
function Component(props: { onActive: () => void }) {
const { onActive } = props
const [state, send] = useMachine(
/* State Machine Definition */,
{
effects: {
onActive: () => {
},
},
},
)
useEffect(() => {
if (state.value === "active") {
onActive()
}
}, [state])
}
You might find the above example redundant and be tempted to write code like this. However, this could lead to bugs.
function Component(props: { onToggle: (isActive: boolean) => void }) {
const { onToggle } = props
const [state, send] = useMachine(
/* State Machine Definition */,
{
effects: {
onActive: () => {
// If props.onToggle is changed, the change will not be reflected.
// Always refers to the first defined value, which can lead to serious bugs.
onToggle()
},
},
},
)
}
Using useRef
to always reference the latest function can avoid this issue.
function Component(props: { onToggle: (isActive: boolean) => void }) {
const onToggle = React.useRef(props.onToggle)
onToggle.current = props.onToggle
const [state, send] = useMachine(
/* State Machine Definition */,
{
effects: {
onActive: () => {
onToggle.current(true)
},
},
},
)
}
However, the potential for human error still exists. Practically, it is recommended to use the constructor to transfer values dependent on React components.
import { createMachine } from "use-machine-ts"
function machine(
props: () => {
initial: "inactive" | "active"
onToggle: ((isActive: boolean) => void) | undefined
}
) {
return createMachine(
{
initial: props().initial,
states: {
inactive: {
on: { TOGGLE: "active" },
effect: "onInactive",
},
active: {
on: { TOGGLE: "inactive" },
effect: "onActive",
},
},
},
{
effects: {
onActive: ({ context }) => {
const { onToggle } = props()
onToggle?.(true)
},
onInactive: ({ context }) => {
const { onToggle } = props()
onToggle?.(false)
},
},
},
)
}
function ToggleButton(props: { onToggle?: (isActive: boolean) => void }) {
const [state, send] = useMachine(machine, {
initial: "inactive",
onToggle: props.onToggle,
})
}
A pre-defined machine in function form can accept a single argument. This argument must be a function. This function is a thin wrapper around useRef
and always returns the latest value.
In addition to a finite number of states, a state machine can have extended states (known as context). The context
property is used to define the initial extended state, and the setContext
function is used to update the extended state.
const [state, send] = useMachine(
{
initial: "inactive",
context: { toggleCount: 0 },
states: {
inactive: {
on: { TOGGLE: "active" },
},
active: {
on: { TOGGLE: "inactive" },
effect: "onActive",
},
},
},
{
effects: {
onActive: ({ setContext }) => {
setContext(ctx => ({ toggleCount: ctx.toggleCount + 1 }))
},
},
},
)
console.log(state.context) // { toggleCount: 0 }
send("TOGGLE")
console.log(state.context) // { toggleCount: 1 }
TypeScript automatically infers the types of context and events, but you can also explicitly define the state machine schema using the $schema
property. This object is not used by the runtime.
The $schema
property has three properties: context
, events
, and strict
.
Property | Type | Required | Description |
---|---|---|---|
context |
any |
Defines the type of the state machine's extended state. | |
events |
object |
Defines the type of the state machine's events. | |
strict |
boolean |
Enables strict mode for the schema. When set to true , automatic inference is disabled, and any context and events not defined in the schema will cause a type error. |
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
$schema: {} as {
context: {
toggleCount: number
}
events: {
TOGGLE: {
timestamp: Date
}
}
},
context: { toggleCount: 0 },
initial: "inactive",
states: {
inactive: {
on: { TOGGLE: "active" },
},
active: {
on: { TOGGLE: "inactive" },
effect: "onActive",
},
},
},
{
effects: {
onActive: ({ event, setContext }) => {
console.log(event)
setContext(ctx => ({ toggleCount: ctx.toggleCount + 1 }))
},
},
},
)
send("TOGGLE") // Type Error !
send({ type: "TOGGLE", timestamp: new Date() }) // OK (^_^)b
// Logs: { type: "TOGGLE", timestamp: 2024-01-01T00:00:00.000Z }
You can enable logging for your state machine if needed. Use the verbose
property to set the logging level.
Value | Description |
---|---|
0 or false |
Disables logging. |
1 |
Logs onlyerrors. (Default) |
2 or true |
Logs errors and debug information. |
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
initial: "inactive",
states: {
inactive: {
on: { TOGGLE: "active" },
},
active: {
on: { TOGGLE: "inactive" },
},
},
},
{
verbose: 2,
},
)
[!NOTE] Logging is disabled if
process.env.NODE_ENV
is"production"
.
To use useSharedMachine
, you must use a state machine created with createSharedMachine
.
import { useSharedMachine, createSharedMachine } from "use-machine-ts"
const sharedMachine = createSharedMachine(
/* State Machine Definition */,
/* State Machine Configuration (Optional) */,
)
const [state, send] = useSharedMachine(sharedMachine)
useSharedMachine
works similarly to useMachine
, but it allows you to manage state transitions from outside. It is essentially a wrapper around useSyncExternalState
and useEffect
. It can be likened to the relationship between atom
and useAtom
.
const machineAtom = atom() /* Initial State */
const [state, setState] = useAtom(machineAtom)
const send = event => {
const nextState = eventToNextState(event, state)
setState(nextState)
}
A shared state machine is an object with six properties: instance
, dispatch
, send
, setContext
, getState
, and subscribe
.
Properties | Type | Description |
---|---|---|
instance |
[Definition, Configuration?] |
The state machine instance. |
dispatch |
function |
A function to send events to the state machine. It is the primitive function used by send and setContext. |
send |
function |
A function to send events to the state machine. |
setContext |
function |
A function to update the state machine's extended state. |
getState |
function |
A function to get the current state of the state machine. |
subscribe |
function |
A function to watch for state changes in the state machine. |
Unlike useMachine
, it does not trigger re-rendering every time the state transitions. This hook provides a function that returns a snapshot of the state, not the current state.
import { useSyncedMachine } from "use-machine-ts"
const [getState, send] = useSyncedMachine({
initial: "inactive",
states: {
inactive: {
on: { TOGGLE: "active" },
},
active: {
on: { TOGGLE: "inactive" },
},
},
})
console.log(getState())
// { value: "inactive", context: undefined,
// event: { type: "$init" }, nextEvents: ["TOGGLE"] }
send("TOGGLE")
console.log(getState())
// { value: "active", context: undefined,
// event: { type: "TOGGLE" }, nextEvents: ["TOGGLE"] }
[!WARNING] Whenever possible, avoid updating state asynchronously in use-machine-ts.
When updating state asynchronously, several considerations arise depending on the specific hook used: useMachine
, useSharedMachine
, or useSyncedMachine
.
Within useMachine
, you can call the send
and setContext
functions asynchronously as long as the component remains mounted. However, if the component has already been unmounted, these functions will instead display an error message indicating that the state cannot be updated:
Cannot dispatch an action to the state machine after the component is unmounted.
Action { type: "SEND", payload: { type: "TOGGLE" } }
For setContext
, the type
property value will be "SET_CONTEXT"
.
To check if the component is unmounted beforehand, you can utilize the isMounted
property within the parameter passed to the effect
function. The isMounted
function returns true
if the component is mounted, and false
otherwise.
import { useMachine } from "use-machine-ts"
const [state, send] = useMachine(
{
initial: "inactive",
states: {
inactive: {
on: { TOGGLE: "active" },
},
active: {
on: { TOGGLE: "inactive" },
},
},
},
{
effects: {
onActive: ({ send, isMounted }) => {
setTimeout(() => {
if (isMounted()) {
send("TOGGLE")
}
}, 1000)
},
},
},
)
In useSharedMachine
, you can call send
, setContext
, or the shared machine's dispatch
asynchronously regardless of the component's mount state. No error or warning messages will be displayed. To check if the component is unmounted beforehand, you can use the isMounted
function similarly to useMachine
.
Within useSyncedMachine
, you cannot call the send
and setContext
functions asynchronously regardless of the component's mount state. These functions are unlocked at the beginning of an effect and locked after its completion. Calling these functions while locked will result in an error message:
Send function not available. Must be used synchronously within an effect.
State { value: "inactive", event: { type: "$init" }, nextEvents: ["TOGGLE"], context: undefined }
Event: { type: "TOGGLE" }