# The Lucy Language Guide
# References
In Lucy there are a few ways to reference other things in a Lucy programs.
An identifier is any sort of named item that can be referenced elsewhere. Lucy has the use
statement for importing other machine, guards and actions.
Some examples of identifiers:
- An use reference:
use './util' { log }
- A named machine
machine one {}
Additionally there are a couple of special types of references in Lucy programs.
# Variables
An action or a guard can be given a name, in which case they are an immutable variable.
use './util' { log }
action logTransition = log
# Symbols
Symbols are an important part of Lucy programs, they allow you to refer to an item that will be passed in when a machine is created. For example:
state idle {
pay => guard(:hasFunds) => purchase
}
Here :hasFunds
refers to a function that is passed in on the JavaScript side. A symbol
can be used anywhere a normal reference can be used.
import createMachine from './register.lucy';
const machine = createMachine({
context: {
funds: 10
},
guards: {
hasFunds: (context) => context.funds > 20
}
});
# Machine
In Lucy a machine can be defined 2 different ways.
# Named machines
The first way to define a machine is by using the machine
keyword followed by a name.
machine toggleMachine {
state on {
toggle => off
}
state off {
toggle => on
}
}
This translates to JavaScript where a function is exported that creates the machine:
import { createMachine } from 'xstate';
export function toggleMachine() {
return createMachine({
states: {
on: {
on: {
toggle: 'off'
}
},
off: {
on: {
toggle: 'on'
}
}
}
});
}
The biggest benefit to naming a machine is when you want to have multiple machines in the same Lucy module. Each will be exported, but you can also use them internally like so:
machine walk {
initial state walk {
delay(10s) => stop
}
final state stop {}
}
machine stoplight {
state red {
invoke(walk) {
done => green
}
}
state green {}
}
# Implicit machines
A Lucy file can have top-level states, in which case there is an implicit machine. The machine
keyword is not needed in this case, and a single machine is exported (as the default JavaScript) export.
state idle {}
out.js
import { createMachine } from 'xstate';
export default function() {
return createMachine({
states: {
idle: {
}
}
});
}
# State
A state block allows us to describe events and transitions from one state to another. A simple state block looks like:
state idle {
click => next
}
state next {}
In the above idle
is the name of the state, click
is an event that occurs within the state, and next
is another state that is transitioned into when the click event occurs.
# Initial state
The initial
state is the state that the machine is first in when the machine starts. Machines are not required to have an initial state, but most FSM runtimes expect one. You can mark a state as initial using the initial
modifier:
initial state idle {}
# Final state
A final state is a state which cannot be transitioned out of.
initial state loading {
error => broken
}
final state broken {
}
# Nested state
You can define nested states by defining a new machine inside any given state.
machine light {
initial state green {
timer => yellow
}
state yellow {
timer => red
}
state red {
timer => green
machine pedestrian {
initial state walk {
countdown => wait
}
state wait {
countdown => stop
}
final state stop {}
}
}
}
# Transitions
Moving from one state to another is called a transition. A transition can occur for a number of reasons, illuminated below. Most commonly however, a transition occurs because of an event, such as a user clicking a button.
# Event
An event occurs based on some action that takes place outside of the state machine. Often these are user-driven actions, like typing into a text input. In Lucy an event is represented by a given name, with an arrow =>
pointing to what should occur given that event.
state idle {
click => loadingUser
}
state loadingUser {
// ...
}
# on(event)
Using click =>
is how you will almost always define transitions in Lucy. It's worth noting that it's a shorthand for the following syntax:
state idle {
on(click) => loadingUser
}
This can rarely be useful. For example, delay
is a keyword in Lucy so this will result in a compilation error:
state idle {
delay => loadingUser
}
However if you use the on() function you can have events named delay:
state idle {
on(delay) => loadingUser
}
# Immediate
An immediate transition is one that occurs immediately upon entering a state. Immediate transitions are useful to perform some side-effect in a temporary state before moving to another state.
In Lucy an immediate is specified by using the =>
token without an event name, like so:
action setUser = :addUserToContext
state loading {
complete => assignData
}
state assignData {
=> setUser => loaded
}
state loaded {}
# Delay
A delay transitions out of state after a given timeframe.
initial state loading {
delay(2s) => idle
}
state idle {
click => done
}
final state done {}
Delays can be specified using either:
-
Integer: Any integer is interpreted as milliseconds:
initial state loading {
delay(200) => idle
}Above the
loading
state transitions toidle
after 200 milliseconds. -
Timeframe: A timeframe is an integer followed by a suffix of either:
- ms: Milliseconds
- s: Seconds
- m: Minutes
initial state wait {
delay(2s) => start
}
state start {}Above the
wait
state transitions tostart
after a delay of 2 seconds. -
Function: a function imported from JavaScript or provided via a symbol can be used to specify a dynamic delay. This is useful when the context of the state machine is needed to determine the length of the delay. The function must return an integer.
state green {
delay(:lightDelay) => yellow
}
state yellow {
delay(:yellowLightDelay) => red
}
final state red {}
# Special events
Additionally Lucy has the concept of 2 special events, @entry
and @exit
. The @
symbol denotes a builtin event type, similar in concept of local variables in Ruby.
# @entry
The @entry
event occurs when first entering a state. It provides a way to perform actions without exiting the state.
state first {
click => second
}
state second {
@entry => action(:log)
// We remain in the `second` state
}
# @exit
The @exit
event occurs when exiting a state. It provides a way to peform actions within needing an intermediate state.
state first {
click => second
@exit => action(:log)
}
final state second {}
# Actions
An action is a way to perform a side-effect either during a transition or during entry/exit of a state. Actions can be used to (among other things):
- Do logging.
- Add a value to the machine's data using
assign
. - Spawn new actor machines.
- Send messages to actors.
Actions can be named at the top level of a machine using the action
keyword. You can also use action
as a function inside of a transition or entry/exit.
# Named actions
Name actions start with keyword action
, then a name for the action, followed by an equal sign and a reference (such as a symbol).
Named actions are useful when you think you might want to reuse the action, or to give the action a more descriptive name.
action logTransition = :log
state ping {
ping => logTransition => pong
}
state pong {
ping => ping
}
# Inline actions
Inline actions are used by calling the action
keyword as a function, followed by the reference.
state ping {
ping => action(:log) => pong
}
state pong {
ping => ping
}
# Assign
An assign is a special kind of action that assigns a value to the machine's data (in XState this is called the context).
# Named actions
Since an assign is a form of an action, you can create a named action for the assign using the action
assignment form:
action addLoadedUser = assign(user, :pluckUser)
state idle {
enter => addLoadedUser => loaded
}
state loaded {}
Here we are assigning the user property to the machine's data. pluckUser
is being used as a reducer. It takes in the event coming from enter
, which might contain data such as a list of users, and returns something that is assigned to user
.
# Inline assigns
Like normal actions, an assign
can also be used inline in a transition. You can use it this way to avoid having to name an action.
state idle {
enter => assign(user, :pluckUser) => loaded
}
state loaded {}
# Guards
A guard is used to interrupt a transition, giving you a chance to dynamically decide if the transition should occur or be rejected.
Like with actions, you can either created named guards, or use guards inline during an event.
# Named guards
A named guard is created using the guard
keyword, followed by a reference that will be called to determine if the transition should proceed.
guard isValidCard = :validCreditCard
state idle {
enter => isValidCard => purchasing
}
state purchasing {}
# Inline guards
A guard can also be called like a function, inline inside of the transition. The argument is the reference used to dynamically determine if the transition should proceed.
state idle {
enter => guard(:validCreditCard) => purchasing
}
state purchasing {}
# Invoke
The keyword invoke
is used to call external code and wait until it is complete. This could be a Promise or a Machine. If it is a machine, the done
event will be sent when the machine reaches its final state.
Invoking promises:
utils.js
export function getUsers() {
return fetch('/users');
}
machine.lucy
state idle {
invoke(:getUsers) {
done => assign(users)
}
}
Invoking other machines:
machine light {
initial state green {
delay(30s) => yellow
}
state yellow {
delay(10s) => red
}
final state red {}
}
machine main {
state idle {
invoke(light) {
done => idle
}
}
}
# Actors
Actors allow you to create new machines and keep a reference to them within your own machine, through an assign reference. You can send messages to the new machine, and they can send messages back to you.
Spawning a machine is a little like using invoke on a machine. Unlike with invoke, a spawned machine does not block your current machine from transitioning.
# Spawning
To create an actor within your machine, use the spawn function call. Spawn can only be used within an assign expression. This is how you save a reference to the spawned machine.
machine todoItem {
state idle {}
}
machine app {
state idle {
new => assign(todo, spawn(todoItem))
}
}
# Sending messages to actors
Once you've spawned a machine you can send messages to it using the send action. The first argument is the referenced actor, the second is an event to send.
machine todoItem {
state idle {
delete => deleting
}
state deleting {
invoke(:deleteTodo) {
done => deleted
}
}
final state deleted {}
}
machine app {
state idle {
new => assign(todo, spawn(todoItem))
delete => send(todo, delete)
}
}
Here during the delete
event of our app we use the send action to tell our referenced todo
actor to receive the delete
event.
# Sending messages to the parent
Likewise, an actor can send messages back to their parent using the special parent
keyword with send
:
machine todoItem {
state idle {
delete => deleting
}
state deleting {
invoke(:deleteTodo) {
done => deleted
}
}
final state deleted {
enter => send(parent, deletedTodo)
}
}
machine app {
state idle {
new => assign(todo, spawn(todoItem))
delete => send(todo, delete)
deleted => action(:updateUI)
}
}