Understanding Actions
Understanding Actions
Actions are side effects that occur during transitions. They let you update context, make API calls, update the DOM, log events, or perform any other effects when moving between states.
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);
return ctx; // Return unchanged context
});
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
They must return the context (modified or unchanged):
const updateUser = action((ctx, event) => {
return {
...ctx,
user: event.user,
lastUpdated: Date.now()
};
});
const machine = createMachine({
idle: state(
transition('login', 'authenticated', updateUser)
),
authenticated: state()
});
Types of Actions
Context Updates
The most common use of actions is updating the machine’s context:
const increment = action((ctx) => ({
...ctx,
count: ctx.count + 1
}));
const setUser = action((ctx, event) => ({
...ctx,
user: event.data
}));
const machine = createMachine({
idle: state(
transition('increment', 'idle', increment),
transition('login', 'authenticated', setUser)
),
authenticated: state()
}, () => ({
count: 0,
user: null
}));
Side Effects
Actions can perform side effects without modifying context:
const logEvent = action((ctx, event) => {
console.log('Event occurred:', event.type);
return ctx; // Context unchanged
});
const showNotification = action((ctx, event) => {
alert(`Status: ${event.message}`);
return ctx;
});
const trackAnalytics = action((ctx, event) => {
analytics.track('state_changed', {
from: ctx.previousState,
to: event.type
});
return ctx;
});
DOM Manipulation
Actions can update the UI directly:
const showSpinner = action((ctx) => {
document.querySelector('#spinner').style.display = 'block';
return ctx;
});
const hideSpinner = action((ctx) => {
document.querySelector('#spinner').style.display = 'none';
return ctx;
});
const updateUI = action((ctx) => {
document.querySelector('#status').textContent = ctx.status;
return ctx;
});
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)
});
return ctx;
});
The reduce Helper
Robot provides a reduce helper as a shorthand for context-updating actions:
import { reduce } from 'robot3';
// Using action
const increment = action((ctx) => ({ ...ctx, count: ctx.count + 1 }));
// Using reduce (same thing, less verbose)
const increment = reduce((ctx) => ({ ...ctx, count: ctx.count + 1 }));
// Common usage
idle: state(
transition('increment', 'idle',
reduce((ctx) => ({ ...ctx, count: ctx.count + 1 }))
)
)
Multiple Actions
You can chain multiple actions - they execute in sequence:
const validate = reduce((ctx, ev) => ({
...ctx,
isValid: ev.data.length > 0
}));
const trackSubmit = action((ctx) => {
analytics.track('form_submitted');
return ctx;
});
const clearForm = reduce((ctx) => ({
...ctx,
data: ''
}));
const machine = createMachine({
editing: state(
transition('submit', 'submitted',
validate, // 1. Validate data
trackSubmit, // 2. Track event
clearForm // 3. Clear form
)
),
submitted: state()
});
Actions execute in order, and each action receives the context returned by the previous action.
Action 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 execute: Actions run in sequence
- Context updated: New context from actions takes effect
- State changes: Machine transitions to target state
- 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. First action
console.log('First:', ctx.count); // Shows 0
return { ...ctx, count: 1 };
}),
action((ctx) => { // 3. Second action
console.log('Second:', ctx.count); // Shows 1
return { ...ctx, count: 2 };
})
)
),
processing: state() // 4. New state with count: 2
}, () => ({ count: 0, isValid: true }));
Actions vs Invoke
Choose between actions and invoke based on your needs:
Use Actions When:
- Making synchronous updates
- Performing immediate side effects
- Updating context based on event data
- No need to wait for completion
// ✅ Good use of actions
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')
)
Action 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
});
return ctx;
});
// 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));
return ctx;
});
// Save context after important transitions
transition('save', 'saved', saveToLocalStorage)
Best Practices
Keep Actions Pure When Possible
Prefer pure context updates over side effects:
// ✅ Good - pure context update
const setUser = reduce((ctx, ev) => ({
...ctx,
user: ev.user
}));
// ⚠️ OK but not ideal - side effect
const setUserAndNotify = action((ctx, ev) => {
showNotification('User updated'); // Side effect
return { ...ctx, user: ev.user };
});
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;
return { ...ctx, processed: true };
});
// ✅ Better - separate concerns
const logEvent = action((ctx, ev) => { /* ... */ });
const persistData = action((ctx) => { /* ... */ });
const trackEvent = action((ctx) => { /* ... */ });
const updateTitle = action((ctx) => { /* ... */ });
const markProcessed = reduce((ctx) => ({ ...ctx, processed: true }));
transition('submit', 'processing',
logEvent,
persistData,
trackEvent,
updateTitle,
markProcessed
)
Avoid Async Operations in Actions
Don’t return promises from actions. Use invoke instead:
// ❌ Bad - async action
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')
)
Return Context
Always return context from actions, even if unchanged:
// ❌ Bad - forgot to return context
const logBad = action((ctx) => {
console.log(ctx);
// Missing return!
});
// ✅ Good - always return context
const logGood = action((ctx) => {
console.log(ctx);
return ctx;
});
Testing Actions
Actions are easy to test since they’re just functions:
const increment = reduce((ctx) => ({
...ctx,
count: ctx.count + 1
}));
// Test without a machine
const ctx = { count: 5 };
const result = increment(ctx, {});
console.assert(result.count === 6);
Common Pitfalls
Mutating Context
Never mutate context directly:
// ❌ Bad - mutating context
const badAction = action((ctx) => {
ctx.count++; // Mutation!
return ctx;
});
// ✅ Good - returning new context
const goodAction = reduce((ctx) => ({
...ctx,
count: ctx.count + 1
}));
Side Effects Without Returning Context
Don’t forget to return context after side effects:
// ❌ Bad
const badSideEffect = action((ctx) => {
console.log('Event!');
// Forgot to return ctx!
});
// ✅ Good
const goodSideEffect = action((ctx) => {
console.log('Event!');
return ctx; // Always return
});
Complex Business Logic
Keep complex logic out of actions when possible:
// ❌ Bad - complex logic in action
const complexAction = action((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 simpleAction = 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