Understanding Actions
Actions are side effects that occur during transitions. They let you make API calls, update the DOM, log events, or perform any other effects when moving between states.
Robot has two helpers that run during a transition:
actionruns a function for its side effects. Its return value is ignored.reduceruns a function that returns the new context. It is how you update context.
In finite state machines, state transitions should be pure and declarative. Actions provide a controlled way to handle the inevitable side effects your application needs while keeping your state machine logic clean and predictable.
Why Actions Matter
State machines are about defining valid states and transitions. But real applications need to do more than just change state - they need to update data, call APIs, notify users, and interact with the outside world. Actions provide a structured way to handle these side effects.
// Without actions - side effects mixed with state logic
if (currentState === 'idle') {
currentState = 'loading';
showSpinner();
trackEvent('loading_started');
fetchData().then(data => {
currentState = 'loaded';
hideSpinner();
});
}
// With actions - clean separation
idle: state(
transition('fetch', 'loading',
action(showSpinner),
action(trackEvent)
)
)
Defining Actions
Actions are defined using the action function and can be added to transitions:
import { createMachine, state, transition, action } from 'robot3';
const logTransition = action((ctx, event) => {
console.log('Transitioning with:', event);
});
const machine = createMachine({
idle: state(
transition('start', 'running', logTransition)
),
running: state()
});
Action Functions
Action functions receive two arguments:
- Context: The current machine context
- Event: The event that triggered the transition
The return value of an action function is ignored. The context passes through to the next step unchanged, so there is no need to return anything:
const logEvent = action((ctx, event) => {
console.log('Event occurred:', event.type);
});
// This does NOT update context - the returned object is discarded
const setUserWrong = action((ctx, event) => ({ ...ctx, user: event.user }));
To update context, use reduce.
Types of Actions
Side Effects
const showNotification = action((ctx, event) => {
alert(`Status: ${event.message}`);
});
const trackAnalytics = action((ctx, event) => {
analytics.track('state_changed', {
from: ctx.previousState,
to: event.type
});
});
DOM Manipulation
Actions can update the UI directly:
const showSpinner = action(() => {
document.querySelector('#spinner').style.display = 'block';
});
const hideSpinner = action(() => {
document.querySelector('#spinner').style.display = 'none';
});
const updateUI = action((ctx) => {
document.querySelector('#status').textContent = ctx.status;
});
API Calls
Actions can trigger API calls (though consider using invoke for async operations):
const saveToAPI = action((ctx) => {
fetch('/api/save', {
method: 'POST',
body: JSON.stringify(ctx.data)
});
});
Updating Context with reduce
reduce is how you change a machine’s context. The reducer function receives the context and event, and whatever it returns becomes the new context:
import { createMachine, state, transition, reduce } from 'robot3';
const increment = reduce((ctx) => ({ ...ctx, count: ctx.count + 1 }));
const setUser = reduce((ctx, event) => ({ ...ctx, user: event.user }));
const machine = createMachine({
idle: state(
transition('increment', 'idle', increment),
transition('login', 'authenticated', setUser)
),
authenticated: state()
}, () => ({
count: 0,
user: null
}));
A reducer must always return the context. If it returns nothing, the context becomes undefined.
Use reduce for context updates and action for side effects.
Multiple Actions and Reducers
You can combine any number of actions and reducers in a transition. They run in the order they are listed, and each one receives the context produced by the reducers before it:
const validate = reduce((ctx, ev) => ({
...ctx,
isValid: ev.data.length > 0
}));
const trackSubmit = action((ctx) => {
analytics.track('form_submitted', { isValid: ctx.isValid });
});
const clearForm = reduce((ctx) => ({
...ctx,
data: ''
}));
const machine = createMachine({
editing: state(
transition('submit', 'submitted',
validate, // 1. Validate data
trackSubmit, // 2. Track event (sees isValid from step 1)
clearForm // 3. Clear form
)
),
submitted: state()
});
Running Code When Entering a State
Reducers and actions can also be passed directly to state. They run whenever the machine enters that state, no matter which transition led there:
const machine = createMachine({
idle: state(
transition('fetch', 'loading')
),
loading: state(
reduce(ctx => ({ ...ctx, error: null, startedAt: Date.now() })),
action(showSpinner),
transition('done', 'loaded'),
transition('fail', 'error')
),
loaded: state(action(hideSpinner)),
error: state(action(hideSpinner))
});
This keeps the context a state depends on next to the state, instead of spread across every transition that leads into it. Entry reducers and actions run after the transition’s own reducers, and don’t run on a transition from a state to itself. See state for the details.
Execution Order
When a transition occurs, this is the execution order:
- Event received: Machine receives an event
- Transition found: Matching transition in current state
- Guards checked: All guards must pass (see Guards)
- Actions and reducers run: In the order they are listed
- Context updated: The context returned by the last reducer takes effect
- State changes: Machine transitions to target state
- Entry actions and reducers run: Those passed to the target
state, unless it’s a transition from a state to itself - Listeners notified: Change listeners called with new state and context
const machine = createMachine({
idle: state(
transition('submit', 'processing',
guard((ctx) => ctx.isValid), // 1. Guard checks
action((ctx) => { // 2. Action sees count: 0
console.log('First:', ctx.count);
}),
reduce((ctx) => ({ ...ctx, count: 1 })), // 3. Reducer sets count: 1
action((ctx) => { // 4. Action sees count: 1
console.log('Second:', ctx.count);
})
)
),
processing: state() // 5. New state with count: 1
}, () => ({ count: 0, isValid: true }));
Actions vs Invoke
Choose between actions and invoke based on your needs:
Use Actions and Reducers When:
- Making synchronous updates
- Performing immediate side effects
- Updating context based on event data
- No need to wait for completion
// ✅ Good use of reduce
idle: state(
transition('update', 'idle',
reduce((ctx, ev) => ({ ...ctx, value: ev.value }))
)
)
Use Invoke When:
- Handling async operations
- Waiting for promises to resolve
- Need to transition based on success/failure
- Want automatic error handling
// ✅ Good use of invoke
idle: state(
transition('fetch', 'loading')
),
loading: invoke(fetchData,
transition('done', 'loaded'),
transition('error', 'error')
)
Patterns
Validation and Enrichment
const validateAndEnrich = reduce((ctx, ev) => {
const cleaned = ev.data.trim();
const isValid = cleaned.length > 0;
return {
...ctx,
data: cleaned,
isValid,
validatedAt: Date.now()
};
});
idle: state(
transition('submit', 'validating', validateAndEnrich)
)
Logging and Analytics
const trackTransition = action((ctx, ev) => {
console.log(`[${new Date().toISOString()}] ${ev.type}`);
analytics.track('transition', {
event: ev.type,
state: ctx.currentState
});
});
// Add to every important transition
transition('submit', 'processing', trackTransition)
Error Handling
const captureError = reduce((ctx, ev) => ({
...ctx,
error: ev.error,
errorTime: Date.now(),
errorMessage: ev.error.message
}));
const clearError = reduce((ctx) => ({
...ctx,
error: null,
errorMessage: null
}));
error: state(
transition('retry', 'loading', clearError)
)
State Persistence
const saveToLocalStorage = action((ctx) => {
localStorage.setItem('appState', JSON.stringify(ctx));
});
// Save context after important transitions
transition('save', 'saved', saveToLocalStorage)
Best Practices
Separate Context Updates from Side Effects
Use reduce to change context and action for everything else:
// ❌ Bad - action's return value is ignored, so user is never set
const setUserAndNotify = action((ctx, ev) => {
showNotification('User updated');
return { ...ctx, user: ev.user };
});
// ✅ Good - one reducer, one action
const setUser = reduce((ctx, ev) => ({ ...ctx, user: ev.user }));
const notifyUserUpdated = action(() => showNotification('User updated'));
transition('update', 'idle', setUser, notifyUserUpdated)
One Responsibility Per Action
Keep actions focused on a single task:
// ❌ Bad - doing too much
const megaAction = action((ctx, ev) => {
console.log('Event:', ev);
localStorage.setItem('data', ctx.data);
analytics.track('event');
document.title = ctx.page;
});
// ✅ Better - separate concerns
const logEvent = action((ctx, ev) => { /* ... */ });
const persistData = action((ctx) => { /* ... */ });
const trackEvent = action((ctx) => { /* ... */ });
const updateTitle = action((ctx) => { /* ... */ });
transition('submit', 'processing',
logEvent,
persistData,
trackEvent,
updateTitle
)
Avoid Async Operations in Actions
Robot doesn’t wait for promises returned from actions or reducers. Use invoke for async work:
// ❌ Bad - the promise is ignored and user is never set
const fetchUserBad = action(async (ctx) => {
const user = await fetch('/api/user').then(r => r.json());
return { ...ctx, user };
});
// ✅ Good - use invoke
loading: invoke(fetchUser,
transition('done', 'loaded',
reduce((ctx, ev) => ({ ...ctx, user: ev.data }))
)
)
Testing Reducers and Actions
Define the functions separately so you can test them without a machine:
function incrementCount(ctx) {
return { ...ctx, count: ctx.count + 1 };
}
// Test without a machine
const result = incrementCount({ count: 5 });
console.assert(result.count === 6);
// Use in a machine
transition('increment', 'idle', reduce(incrementCount))
Common Pitfalls
Returning Context from an Action
An action’s return value is discarded, so returning a new context from it does nothing:
// ❌ Bad - context is not updated
const setDone = action((ctx) => ({ ...ctx, done: true }));
// ✅ Good
const setDone = reduce((ctx) => ({ ...ctx, done: true }));
Forgetting to Return from a Reducer
A reducer’s return value replaces the context, so always return it:
// ❌ Bad - context becomes undefined
const logBad = reduce((ctx) => {
console.log(ctx);
});
// ✅ Good - use an action for side effects
const logGood = action((ctx) => {
console.log(ctx);
});
Mutating Context
An action receives the context object itself, so mutating it does change the context:
const addItem = action((ctx, ev) => {
ctx.items.push(ev.item);
});
This works, but prefer returning a new object from reduce. Explicit context updates are easier to follow and test, and code that compares context objects by reference (like UI framework change detection) won’t notice an in-place mutation.
const addItem = reduce((ctx, ev) => ({
...ctx,
items: [...ctx.items, ev.item]
}));
Complex Business Logic
Keep complex logic out of reducers when possible:
// ❌ Bad - complex logic in the reducer
const complexReducer = reduce((ctx, ev) => {
let newValue = ctx.value;
if (ev.type === 'increment') {
newValue += ev.amount || 1;
} else if (ev.type === 'decrement') {
newValue -= ev.amount || 1;
}
// ... more logic
return { ...ctx, value: newValue };
});
// ✅ Better - separate functions
function calculateNewValue(ctx, ev) {
if (ev.type === 'increment') return ctx.value + (ev.amount || 1);
if (ev.type === 'decrement') return ctx.value - (ev.amount || 1);
return ctx.value;
}
const simpleReducer = reduce((ctx, ev) => ({
...ctx,
value: calculateNewValue(ctx, ev)
}));
Related Topics
- Transitions - Where actions execute
- Guards - Conditions checked before actions
- Events - What triggers actions
- action API - Technical reference for the action function
- reduce API - Technical reference for the reduce helper
- invoke API - For async operations instead of actions