Skip to main content

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.

you will learn:

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
})
tip

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:

Live Sandbox
1234567891011121314151617
const trafficLightAtom = atom('trafficLight', () => {
const store = injectMachineStore(state => [
state('green').on('timer', 'yellow'),
state('yellow').on('timer', 'red'),
state('red').on('timer', 'green'),
])

return store
})

const ecosystem = createEcosystem({ id: 'send-example' })
const { store } = ecosystem.getInstance(trafficLightAtom)

const initialState = store.getState()
store.send('timer')
const nextState = store.getState()
const output = { initialState, nextState }
tip

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.

Live Sandbox
123456789101112131415161718
const trafficLightAtom = atom('trafficLight', () => {
const store = injectMachineStore(state => [
state('green').on('timer', 'yellow'),
state('yellow').on('timer', 'red'),
state('red').on('timer', 'green'),
])

return store
})

const ecosystem = createEcosystem({ id: 'is-example' })

// both .is and .send are bound function properties (destructuring is fine):
const { is, send } = ecosystem.getInstance(trafficLightAtom).store

send('timer')
send('timer')
const output = is('red')

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)
warning

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:

Live Sandbox
12345678910111213141516171819202122232425262728293031323334
const themeAtom = atom('theme', () => {
const store = injectMachineStore(
state => [
state('light').on('toggle', 'dark'),
state('dark').on('toggle', 'light'),
],
{ count: 0 },
{
onTransition: machine =>
machine.setContext(context => ({ count: context.count + 1 })),
}
)

// you can alias store methods on exports for use in `useAtomState`:
return api(store).setExports({ send: store.send })
})

function Theme() {
const [{ context, value }, { send }] = useAtomState(themeAtom)

return (
<div style={value === 'dark' ? { background: '#444', color: '#fff' } : {}}>
<label>
<input
checked={value === 'dark'}
onChange={() => send('toggle')}
type="checkbox"
/>
<span>{value} mode</span>
</label>
<div>Toggle Count: {context.count}</div>
</div>
)
}

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 }
)
note

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 }
)
note

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:

Live Sandbox
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
const doorAtom = atom('door', () => {
const store = injectMachineStore(
state => {
const startTimeout = machine => {
const { handle } = machine.getContext()

if (handle) clearTimeout(handle)

const newHandle = setTimeout(() => {
machine.setContext({ handle: null })
machine.send('timeout')
}, 1000)

machine.setContext({ handle: newHandle })
}

return [
state('open').on('click', 'closing'),
state('opening')
.on('click', 'closing')
.on('timeout', 'open')
.onEnter(startTimeout),
state('closed').on('click', 'opening'),
state('closing')
.on('click', 'opening')
.on('timeout', 'closed')
.onEnter(startTimeout),
]
},
{ handle: null }
)

return store
})

function Machine() {
const { value } = useAtomValue(doorAtom)
const { send } = useAtomInstance(doorAtom).store

return (
<>
<div>State: {value}</div>
<button onClick={() => send('click')}>Fire Click</button>
</>
)
}

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.

Live Sandbox
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
const maybeMovingAtom = atom('maybeMoving', () => {
const store = injectMachineStore(
state => [
// the guard! Prevent this transition if we're frozen:
state('idle').on('move', 'moving', context => !context.isFrozen),
state('moving')
.on('stop', 'idle')
.onEnter(machine => {
setTimeout(() => machine.send('stop'), 1000)
}),
],
{ isFrozen: false }
)

return api(store).setExports({
toggleFreeze: () =>
store.setContext(context => ({ isFrozen: !context.isFrozen })),
is: store.is,
move: () => store.send('move'),
})
})

function Controls() {
const [{ context }, { toggleFreeze, is, move }] =
useAtomState(maybeMovingAtom)

return (
<div>
<div>- {context.isFrozen ? 'Frozen' : 'All Systems Go'} -</div>
<div
style={{
background: 'cyan',
height: 50,
left: 0,
position: 'relative',
transition: 'all 1s',
width: 50,
...(is('moving') && { left: 50 }),
}}
/>
<button onClick={toggleFreeze}>
{context.isFrozen ? 'Unfreeze' : 'Freeze'}
</button>
<button onClick={move}>
Move {context.isFrozen ? '(Does Nothing)' : ''}
</button>
</div>
)
}

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:

Live Sandbox
1234567891011121314151617181920212223242526272829303132333435363738394041424344
const cyclingAtom = atom('cycling', () => {
const store = injectMachineStore(
state => [
state('a').on('cycle', 'b'),
state('b').on('cycle', 'c'),
state('c').on('cycle', 'a'),
],
{ isPaused: false },
{
guard: (state, nextValue) => !state.context.isPaused || nextValue !== 'b',
}
)

injectEffect(() => {
const handle = setInterval(() => store.send('cycle'), 1000)

return () => clearInterval(handle)
}, [])

return api(store).setExports({
togglePause: () =>
store.setContext(context => ({ isPaused: !context.isPaused })),
})
})

function Cycler() {
const [{ context, value }, { togglePause }] = useAtomState(cyclingAtom)

return (
<div>
<div>
{value}{' '}
{context.isPaused
? value === 'a'
? '(paused)'
: '(finishing cycle...)'
: null}
</div>
<button onClick={togglePause}>
{context.isPaused ? 'Resume' : 'Pause'}
</button>
</div>
)
}

Recap

  • Use injectMachineStore() to create a MachineStore.
    • state.on() adds transitions between states.
    • state.onEnter() and state.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() or machineStore.setContextDeep()
  • Use guards to prevent valid transitions.
  • Add a universal guard and onTransition listener via the 3rd param to injectMachineStore().

Next Steps

Check out the API docs for: