A reactive finite state machine library built on top of Valtio with inspiration from Simple State Machine.
- ⚡ Reactive state management powered by Valtio
- 🔒 Type-safe with TypeScript generics
- 🔄 Finite state machine pattern for predictable state transitions
- 📜 Context and transition history tracking
- ⚛️ Seamless React integration
- 🔌 Event system for cross-cutting concerns
- 🔧 Chainable API for expressive code
- 🧪 Well-tested with extensive coverage
npm install valtio-fsm valtio
# or
yarn add valtio-fsm valtio
# or
pnpm add valtio-fsm valtio
import { createMachine } from 'valtio-fsm';
import { useSnapshot } from 'valtio';
// Define state and context types
type LightState = 'green' | 'yellow' | 'red';
type LightContext = {
duration: number;
cycleCount: number;
};
// Create a state machine
const trafficLight = createMachine<LightState, LightContext>(
'green', // Initial state
{
green: {
transitions: ['yellow'],
onExit: (ctx) => { ctx.duration = 5; }
},
yellow: {
transitions: ['red'],
onEnter: (ctx) => { ctx.cycleCount++; }
},
red: {
transitions: ['green']
}
},
// Initial context
{ duration: 30, cycleCount: 0 },
// Options
{ enableHistory: true }
);
// Use in React components
function TrafficLightComponent() {
const snap = useSnapshot(trafficLight.getStore());
return (
<div>
<div className={`light ${snap.state}`} />
<p>State: {snap.state}</p>
<p>Duration: {snap.context.duration}</p>
<p>Cycle count: {snap.context.cycleCount}</p>
<button
onClick={() => {
if (trafficLight.canMoveTo('yellow')) {
trafficLight.moveTo('yellow');
}
}}
>
Next
</button>
</div>
);
}
A finite state machine (FSM) is a mathematical model that has a finite number of states and transitions between those states. At any given time, the machine can be in only one state.
Each state represents a condition of the system. In Valtio FSM, states are defined as string literals.
Transitions define how the machine can move from one state to another. Only explicitly defined transitions are allowed.
Context stores data that persists across state transitions. It's a reactive Valtio proxy object that triggers updates when modified.
Handlers are functions executed when entering or exiting a state:
onEnter
: Runs when entering a stateonExit
: Runs when leaving a state
Both handlers receive the current context and optional payload data.
Events provide a way to respond to external triggers without changing the state. Event handlers can modify the context.
createMachine<TState, TContext>(
initialState: TState,
stateConfig: StateConfig<TState, TContext>,
initialContext?: TContext,
options?: MachineOptions<TState>
): ChainableStateMachine<TState, TContext>
Creates a new state machine instance.
initialState
: The starting state of the machine.stateConfig
: Configuration object defining states, transitions, and handlers.initialContext
: (Optional) Initial context data.options
: (Optional) Machine configuration options.
A chainable state machine instance.
Configuration defining states, their allowed transitions, and handlers.
type StateConfig<TState, TContext> = {
[key in TState]?: {
transitions: TState[];
onEnter?: (context: TContext, payload?: unknown) => void;
onExit?: (context: TContext, payload?: unknown) => void;
};
};
Options for customizing the state machine.
interface MachineOptions<TState> {
enableHistory?: boolean; // Whether to track transition history
historySize?: number; // Maximum size of history (default: 100)
onTransition?: TransitionListener<TState>; // Global transition listener
}
The state machine instance returned by createMachine
provides these methods:
moveTo(state, payload?)
: Transitions to a new state with optional payloadisIn(state)
: Checks if the machine is in a specific statecanMoveTo(state)
: Checks if a transition to the given state is validresetContext()
: Resets context to initial values
current
: Current state value (readonly)context
: Current context object (readonly)getStore()
: Returns the Valtio store for use withuseSnapshot
onTransition(listener)
: Registers a transition listenerwhenIn(state, callback)
: Registers a callback for a specific stateonContextChange(listener)
: Registers a context change listener
getHistory()
: Returns the transition historyclearHistory()
: Clears the transition historyenableHistory(enable?)
: Enables or disables history trackingsetHistorySize(size)
: Sets the maximum history size
setTransitions(state, transitions)
: Sets allowed transitions for a stateaddTransition(state, transition)
: Adds a single transition to a stateremoveTransition(state, transition)
: Removes a transition from a statesetHandler(state, type, handler)
: Sets an onEnter or onExit handler
on(eventName, handler)
: Registers an event handleronce(eventName, handler)
: Registers a one-time event handleroff(eventName, handler?)
: Removes an event handlerfire(eventName, payload?)
: Fires an event
import { createMachine } from 'valtio-fsm';
import { snapshot } from 'valtio';
// Define a more complex context
type UserContext = {
isAuthenticated: boolean;
profile: {
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
};
lastLogin: Date | null;
};
const authMachine = createMachine<'guest' | 'authenticated', UserContext>(
'guest',
{
guest: {
transitions: ['authenticated'],
onExit: (ctx, userData) => {
// Update context when transitioning from guest to authenticated
Object.assign(ctx.profile, userData);
ctx.isAuthenticated = true;
ctx.lastLogin = new Date();
}
},
authenticated: {
transitions: ['guest'],
onExit: (ctx) => {
// Clear sensitive data when logging out
ctx.isAuthenticated = false;
}
}
},
{
isAuthenticated: false,
profile: {
name: '',
email: '',
preferences: {
theme: 'light',
notifications: true
}
},
lastLogin: null
}
);
// Track context changes
authMachine.onContextChange((context, changes) => {
console.log('Context updated:', changes);
// You could persist changes to localStorage here
localStorage.setItem('userPreferences', JSON.stringify(context.profile.preferences));
});
// Login
authMachine.moveTo('authenticated', {
name: 'John Doe',
email: '[email protected]'
});
// Update preferences - this will trigger the context change listener
authMachine.getStore().context.profile.preferences.theme = 'dark';
// Get an immutable snapshot for comparison or debugging
const currentSnapshot = snapshot(authMachine.getStore());
console.log(currentSnapshot);
// Reset to initial state and context
authMachine.moveTo('guest').resetContext();
import { createMachine } from 'valtio-fsm';
type TodoState = 'idle' | 'loading' | 'error';
type TodoContext = {
items: { id: number; text: string; completed: boolean }[];
error: string | null;
filter: 'all' | 'active' | 'completed';
};
const todoMachine = createMachine<TodoState, TodoContext>(
'idle',
{
idle: { transitions: ['loading', 'error'] },
loading: { transitions: ['idle', 'error'] },
error: { transitions: ['idle'] }
},
{
items: [],
error: null,
filter: 'all'
}
);
// Register event handlers
todoMachine
.on('add', (ctx, text) => {
ctx.items.push({
id: Date.now(),
text: String(text),
completed: false
});
})
.on('toggle', (ctx, id) => {
const item = ctx.items.find(item => item.id === id);
if (item) {
item.completed = !item.completed;
}
})
.on('remove', (ctx, id) => {
ctx.items = ctx.items.filter(item => item.id !== id);
})
.on('setFilter', (ctx, filter) => {
ctx.filter = filter;
})
.on('clearCompleted', (ctx) => {
ctx.items = ctx.items.filter(item => !item.completed);
});
// Register one-time event handler
todoMachine.once('initialize', (ctx, initialItems) => {
ctx.items = initialItems || [];
});
// Fire events
todoMachine.fire('add', 'Learn Valtio FSM');
todoMachine.fire('add', 'Build an awesome app');
todoMachine.fire('toggle', todoMachine.context.items[0].id);
todoMachine.fire('setFilter', 'active');
import { createMachine } from 'valtio-fsm';
type EditorState = 'view' | 'edit' | 'preview' | 'locked';
type EditorContext = {
content: string;
permissions: {
canEdit: boolean;
canDelete: boolean;
};
lastModified: Date | null;
};
// Create with minimal config, build it dynamically
const editorMachine = createMachine<EditorState, EditorContext>(
'view',
{
view: { transitions: ['edit'] }
},
{
content: '',
permissions: {
canEdit: false,
canDelete: false
},
lastModified: null
}
);
// Dynamically add states, transitions and handlers
editorMachine
.addTransition('view', 'locked')
.addTransition('edit', 'preview')
.addTransition('edit', 'view')
.addTransition('preview', 'edit')
.addTransition('preview', 'view')
.addTransition('locked', 'view')
.setHandler('edit', 'onEnter', (ctx) => {
// Save original content for undo
ctx._originalContent = ctx.content;
})
.setHandler('edit', 'onExit', (ctx) => {
// Update last modified timestamp
ctx.lastModified = new Date();
});
// Update transitions based on permissions
function updatePermissions(canEdit: boolean, canDelete: boolean) {
editorMachine.getStore().context.permissions = { canEdit, canDelete };
// If user can't edit, remove edit transition
if (!canEdit) {
editorMachine.removeTransition('view', 'edit');
} else {
editorMachine.addTransition('view', 'edit');
}
}
import { createMachine } from 'valtio-fsm';
import { useSnapshot } from 'valtio';
import { useState, useEffect } from 'react';
type WizardState = 'step1' | 'step2' | 'step3' | 'complete';
type WizardContext = {
formData: {
name: string;
email: string;
plan: string;
agreeToTerms: boolean;
};
validationErrors: Record<string, string>;
};
const wizardMachine = createMachine<WizardState, WizardContext>(
'step1',
{
step1: {
transitions: ['step2'],
onExit: (ctx) => validateStep1(ctx)
},
step2: {
transitions: ['step1', 'step3'],
onExit: (ctx) => validateStep2(ctx)
},
step3: {
transitions: ['step2', 'complete'],
onExit: (ctx) => validateStep3(ctx)
},
complete: {
transitions: []
}
},
{
formData: {
name: '',
email: '',
plan: '',
agreeToTerms: false
},
validationErrors: {}
}
);
// Validation functions
function validateStep1(ctx) {
const errors = {};
if (!ctx.formData.name) errors.name = 'Name is required';
if (!ctx.formData.email) errors.email = 'Email is required';
ctx.validationErrors = errors;
return Object.keys(errors).length === 0;
}
function validateStep2(ctx) {
const errors = {};
if (!ctx.formData.plan) errors.plan = 'Please select a plan';
ctx.validationErrors = errors;
return Object.keys(errors).length === 0;
}
function validateStep3(ctx) {
const errors = {};
if (!ctx.formData.agreeToTerms) {
errors.agreeToTerms = 'You must agree to terms';
}
ctx.validationErrors = errors;
return Object.keys(errors).length === 0;
}
// React wizard component
function Wizard() {
const snap = useSnapshot(wizardMachine.getStore());
function handleNext() {
const currentState = wizardMachine.current;
if (currentState === 'step1') wizardMachine.moveTo('step2');
else if (currentState === 'step2') wizardMachine.moveTo('step3');
else if (currentState === 'step3') wizardMachine.moveTo('complete');
}
function handleBack() {
const currentState = wizardMachine.current;
if (currentState === 'step2') wizardMachine.moveTo('step1');
else if (currentState === 'step3') wizardMachine.moveTo('step2');
}
function updateFormData(field, value) {
wizardMachine.getStore().context.formData[field] = value;
}
return (
<div className="wizard">
<div className="wizard-progress">
{['step1', 'step2', 'step3', 'complete'].map(step => (
<div
key={step}
className={`wizard-step ${snap.state === step ? 'active' : ''}`}
/>
))}
</div>
{snap.state === 'step1' && (
<div className="wizard-page">
<h2>Personal Information</h2>
<input
type="text"
placeholder="Name"
value={snap.context.formData.name}
onChange={(e) => updateFormData('name', e.target.value)}
/>
{snap.context.validationErrors.name && (
<p className="error">{snap.context.validationErrors.name}</p>
)}
<input
type="email"
placeholder="Email"
value={snap.context.formData.email}
onChange={(e) => updateFormData('email', e.target.value)}
/>
{snap.context.validationErrors.email && (
<p className="error">{snap.context.validationErrors.email}</p>
)}
</div>
)}
{/* Step 2 and 3 components here */}
{snap.state === 'complete' && (
<div className="wizard-complete">
<h2>Thank you!</h2>
<p>Your registration is complete.</p>
</div>
)}
<div className="wizard-controls">
{snap.state !== 'step1' && snap.state !== 'complete' && (
<button onClick={handleBack}>Back</button>
)}
{snap.state !== 'complete' && (
<button onClick={handleNext}>
{snap.state === 'step3' ? 'Complete' : 'Next'}
</button>
)}
</div>
</div>
);
}
- Keep States Minimal: Create states only for distinct UI/app conditions.
- Use Explicit Transitions: Always define allowed transitions for better predictability.
- Avoid Too Many States: Use context for variations within a state.
- Keep Context Serializable: Avoid functions or complex objects in context when possible.
- Normalize Context Data: Structure context data for easy access and updates.
- Use Event Handlers: For complex context updates, use events rather than direct mutations.
- Keep Handlers Pure: Avoid side effects in handlers when possible.
- Error Handling: Wrap handler code in try/catch for resilience.
- Validation in Exit Handlers: Use onExit handlers to validate before transitions.
- Use Snapshots: Always use
useSnapshot(machine.getStore())
in React components. - Subscribe Selectively: When possible, subscribe to specific parts of the store for better performance.
- Reset on Unmount: Consider resetting context when components unmount.
- Limit History Size: Set appropriate history size for your application needs.
- Clean Up Listeners: Remove listeners when no longer needed.
- Batch Context Updates: Group related context changes to reduce re-renders.
Use the history features to debug state transition issues:
// Enable history if not already enabled
machine.enableHistory();
// Get all transitions
const history = machine.getHistory();
console.log('Transition history:', history);
// Listen to transitions
machine.onTransition((from, to, payload) => {
console.log(`Transition: ${from} → ${to}`, payload);
});
Track context changes to debug data flow issues:
machine.onContextChange((context, changes) => {
console.log('Context changes:', changes);
});
MIT