Walkthrough
The state machine is one of the most important tools a statesmith can have in his belt. XState is the current king of the JavaScript state machine world. Zedux is not out to change that, in fact we may build an official XState Zedux integration soon.
However, Zedux does provide a minimal state machine implementation via the @zedux/machines
package. These state machines use a high-level, TypeScript-first design that's meant to handle simple cases extremely well. For more power, use XState.
How to create simple TS-first state machines and add transition guards and listeners.
Creating a machine
The @zedux/machines
package provides a single high-level injector for creating automatically-typed state machines: injectMachineStore()
.
Pass a function to injectMachineStore()
that accepts a single state
factory function and returns an array of states. The first state in the array becomes the initial state.
import { injectMachineStore } from '@zedux/machines'
import { atom } from '@zedux/react'
const trafficLightAtom = atom('trafficLight', () => {
const store = injectMachineStore(state => [
state('green'), // <- the initial state
state('yellow'),
state('red'),
])
return store
})
This creates a state machine with 3 states: green
, yellow
, and red
. green
is the initial state. But this machine is stuck in green
forever! To change that, you need to give it transitions:
const store = injectMachineStore(state => [
state('green').on('timer', 'yellow'),
state('yellow').on('timer', 'red'),
state('red').on('timer', 'green'),
])
Use .on(event, nextState)
to add transitions to the machine's states. Now the machine can transition from green
-> yellow
-> red
-> green
-> etc. in a loop. The machine will transition when it receives the timer
event.
Now ... how do you send that event to the machine?
MachineStore
injectMachineStore()
returns a special store called a MachineStore. MachineStore extends the Zedux Store class and adds some state machine functionality.
import { injectMachineStore, MachineStore } from '@zedux/machines'
import { atom } from '@zedux/react'
const exampleAtom = atom('example', () => {
const store = injectMachineStore(() => [])
store instanceof MachineStore // true
})
While you can instantiate the MachineStore class yourself, it's highly recommended to use helpers like injectMachineStore()
for the automatic TS types.
.send()
Use this to send events to the store, triggering state transitions:
The store returned from the state factory is the atom instance's .store
. Since this example returned a MachineStore, you can access the machine's methods directly on the instance's store - e.g. instance.store.send('timer')
. This has full TypeScript support too!
For TypeScript users, .send()
only accepts strings that were specified as event names during machine creation. In this example, passing anything but the string 'timer'
gives a TS error:
send('time') // Argument of type '"time"' is not assignable to parameter of type '"timer"'
The machine will only transition if the current state has a transition specified for the passed event.
const store = injectMachineStore(state => [
state('idle').on('move', 'animating')
state('animating').on('finish', 'idle')
])
store.getValue() // 'idle'
store.send('finish') // does nothing
store.getValue() // 'idle'
store.send('move')
store.getValue() // 'animating'
.is()
Returns true if the machine's current state is the passed state string.
For TS users, .is()
only accepts strings that were specified as state names during machine creation. In this example, passing anything but the strings 'green'
, 'yellow'
, or 'red'
gives a TS error:
is('yello') // Argument of type '"yello"' is not assignable to parameter of type '"green" | "yellow" | "red"'
.getValue()
Returns the machine's current string value:
store.getValue() // green
store.send('timer')
store.getValue() // yellow
State Shape
The MachineStore's state is an object like the following:
store.getState()
// {
// context: undefined,
// value: 'green',
// }
.value
is the current string state of the machine.
// These two lines are equivalent:
machineStore.getState().value === 'green'
machineStore.is('green')
We'll look at .context
next:
Context
MachineStores can hold extra state, besides the value string. This extra state is called context. Context must be an object (or undefined
).
You can pass the initial context as the 2nd param to injectMachineStore()
:
const store = injectMachineStore(statesFactory, initialContext)
.getContext()
Returns the current context value. An alias for machineStore.getState().context
:
// These two lines are equivalent:
machineStore.getState().context
machineStore.getContext()
.setContext()
Sets the store's context value. Accepts function overloads.
// These are all equivalent:
machineStore.setState(state => ({ ...state, context: newContext }))
machineStore.setContext(newContext)
machineStore.setContext(context => newContext)
.setContextDeep()
Deeply merges the passed context value into the existing context. Accepts function overloads.
// These are all equivalent:
machineStore.setStateDeep({ context: newContext })
machineStore.setContextDeep(newContext)
machineStore.setContextDeep(context => newContext)
Just like .setStateDeep()
, .setContextDeep()
is only for updating state - it cannot be used to delete keys. Use .setContext()
for that.
Example
Here's an example using context to keep track of how many times a theme toggler toggles:
This example also made use of an onTransition
function. This is called a listener.
Listeners
Listeners can be used to run side effects when the state machine transitions to a new state. There are 3 kinds: onEnter
, onLeave
, and the universal onTransition
.
onEnter
This listener is attached to individual states via state.onEnter()
. Here's an example using .onEnter()
to send a request when the machine enters the 'fetching' state:
const requestStore = injectMachineStore(
state => [
state('idle').on('fetch', 'fetching'),
state('fetching')
.on('succeed', 'success')
.on('fail', 'failure')
.onEnter(async machine => {
try {
const data = await fetch('/todos').then(data => data.json())
machine.setContextDeep({ data })
machine.send('succeed')
} catch (error) {
machine.setContextDeep({ error })
machine.send('fail')
}
}),
],
{ data: null, error: null }
)
For TS users: .onEnter()
has limited type support, since the machine's full type hasn't been inferred yet. The above example will work because .onEnter()
is placed after the two .on()
calls for the 'fetching' state. When you need access to better types, use onTransition
instead.
onLeave
This listener is attached to individual states via state.onLeave()
. Here's an example using both .onEnter()
and .onLeave()
to set and clear a timeout:
const timeoutStore = injectMachineStore(
state => [
state('idle').on('startTimer', 'waiting'),
state('waiting')
.on('cancel', 'idle')
.on('timeout', 'idle')
.onEnter(machine => {
const handle = setTimeout(() => {
machine.setContext({ handle: null })
machine.send('timeout')
}, 1000)
machine.setContext({ handle })
})
.onLeave(async machine => {
const { handle } = machine.getContext()
if (!handle) return
clearTimeout(handle)
machine.setContext({ handle: null })
}),
],
{ handle: null }
)
For TS users: Like .onEnter()
, .onLeave()
has limited type support, since the machine's full type hasn't been inferred yet. When you need access to better types, use onTransition
instead.
onTransition
This is the catch-all listener that will be called every time the state machine transitions. This listener has full type support, so use this when onEnter
and onLeave
types are insufficient.
The 3rd parameter to injectMachineStore()
is a config object:
const store = injectMachineStore(statesFactory, initialContext, config)
Pass onTransition
as part of this config
const toggleMachine = injectMachineStore(
state => [state('on').on('toggle', 'off'), state('off').on('toggle', 'on')],
{ onTransition }
)
Contrived Example
The obligatory garage door state machine example:
Guards
Guards are functions that conditionally prevent the machine from performing a valid transition. Guards can be set per-transition via the 3rd param to state.on()
:
const maybeMoveStore = injectMachineStore(
state => [state('idle').on('move', 'moving', context => !context.isFrozen)],
{ isFrozen: false }
)
These guards receive the machine's context as their only parameter. Return true
to allow the transition or any falsy value to prevent it. The above machine will only transition from idle
to moving
if context.isFrozen
is falsy.
You can also configure the MachineStore with a universal guard via the config object:
...
const guard = (state, nextValue) => {
// use the current state object and/or nextValue string to determine if the
// transition should be allowed (return true if yes)
}
const store = injectMachineStore(statesFactory, initialContext, { guard })
This guard receives the full current state ({ context, value }
) and the name of the pending transition state. Here's an example using a universal guard to pause the machine after allowing it to cycle back to the start:
Recap
- Use
injectMachineStore()
to create a MachineStore.state.on()
adds transitions between states.state.onEnter()
andstate.onLeave()
add listeners to individual states.
- Context is the MachineStore's "extra state". It must be an object (or
undefined
).- Set initial context with the 2nd param to
injectMachineStore()
. - Get context with
machineStore.getContext()
- Set context with
machineStore.setContext()
ormachineStore.setContextDeep()
- Set initial context with the 2nd param to
- Use guards to prevent valid transitions.
- Add a universal guard and
onTransition
listener via the 3rd param toinjectMachineStore()
.
Next Steps
Check out the API docs for: