Strongly typed, declarative utility for state machine modeling.
Simply Stated is NOT a state management lib - It is all about state description.
- Describe your state shape and behavior.
- Drive it using your preferred state management solution.
See adapters & examples for popular state management libraries.
The below example showcases an abstract processing worker state.
Define all possible states (names) and specify the shape of the data carried by each of them.
With comments
import { combineStates, defineState } from 'simply-stated';
type Job = {
id: string;
data: Buffer;
};
const workerMachine = combineStates(
// State is represented by a string (state name)
defineState('Idle'),
// It might carry a data
defineState('Listening').withData<Date>(),
// Multiple names define multiple states of the same shape
defineState('Queued', 'Processing').withData<Job>(),
defineState('Failed').withData<{ reason: string }>(),
).createMachine(/* ... */);Just code
import { combineStates, defineState } from 'simply-stated';
type Job = {
id: string;
data: Buffer;
};
const workerMachine = combineStates(
defineState('Idle'),
defineState('Listening').withData<Date>(),
defineState('Queued', 'Processing').withData<Job>(),
defineState('Failed').withData<{ reason: string }>(),
).createMachine(/* ... */);List allowed events for each of defined state and the results (next states) of processing those events.
With comments
const workerMachine = combineStates(/* ... */).createMachine(state => ({
// Each defined state has to be listed as root-level property
Idle: {
// Nested properties define events allowed ONLY in a given state.
// Property name becomes an event type ({ type: 'started' }).
// Each event handler returns resulting state.
started: () => state.Listening(new Date()),
},
Listening: {
// Event handler might define a payload by specifying SECOND param
// ({ type: 'consumed', payload: Job })
consumed: (_, job: Job) => state.Queued(job),
failed: (
// The first param is the data of a given state ('Listening' has a Date)
dateOfStart,
{ critical, reason }: { critical: boolean; reason: string },
) => {
if (critical) return state.Failed({ reason });
// Self transition - State does not have to change
return state.Listening(dateOfStart);
},
},
Queued: {
picked: job => state.Processing(job),
},
// States might skip defining their events
Processing: {},
Failed: {},
// A star group define events that are allowed in ANY state (cross-state events).
// The star group is optional; your machine may not define any cross-state events
'*': {
reset: () => state.Idle(),
// Cross-state events DOES NOT have access to the state's data.
// The payload of cross-state events is specified as the FIRST param
failForced: (reason: string) => state.Failed({ reason }),
},
}));Just code
const workerMachine = combineStates(/* ... */).createMachine(state => ({
Idle: {
started: () => state.Listening(new Date()),
},
Listening: {
consumed: (_, job: Job) => state.Queued(job),
failed: (
dateOfStart,
{ critical, reason }: { critical: boolean; reason: string },
) => {
if (critical) return state.Failed({ reason });
return state.Listening(dateOfStart);
},
},
Queued: {
picked: job => state.Processing(job),
},
Processing: {},
Failed: {},
'*': {
reset: () => state.Idle(),
failForced: (reason: string) => state.Failed({ reason }),
},
}));This step depends on your application design and the way it manages the state.
- Backend system might process events and use the state machine to validate and derive the current object state.
- Frontend applications usually store the current state with state management libraries (redux, zustand etc.). See the adapters & examples.
Either way, the app calls the transition function passing the base state
and the event to compute the resulting state.
With comments
import type { EventOf, StateOf } from 'simply-stated';
const { state } = workerMachine;
// The StateOf extracts the union of types of all defined states.
// You can specify a second type param to extract specific type(s).
// StateOf<typeof state, 'Listening' | 'Failed'>
type MachineState = StateOf<typeof state>;
let currentState = state.Listening(new Date()) as MachineState;
// The EventOf works exactly like StateOf but for events
// EventOf<typeof event, 'failed'>
type MachineEvent = EventOf<typeof workerMachine.event>;
const processEvents = (eventsToProcess: MachineEvent[]) => {
// `transition` is a reducer function: transition(State, Event): State
const nextState = eventsToProcess.reduce(
workerMachine.transition,
currentState,
);
currentState = nextState;
return nextState;
};
// nextEvent: { type: 'consumed', payload: { id: '0', data: Buffer<...> } }
const nextEvent = workerMachine.event.consumed({
id: '0',
data: Buffer.from('data'),
});
// resultingState: { name: 'Queued', data: { id: '0', data: Buffer<...> } }
const resultingState = processEvents([nextEvent]);
// The 'is' helper works by comparing the state names
// currentState.name === state.Queued.stateName
// || currentState.name === state.Processing.stateName
if (currentState.is(state.Queued, state.Processing)) {
// It narrows the state type.
// The .data property is available for Queued and Processing states
console.info('Job already consumed. Details:', currentState.data);
}Just code
import type { EventOf, StateOf } from 'simply-stated';
const { state } = workerMachine;
type MachineState = StateOf<typeof state>;
let currentState = state.Listening(new Date()) as MachineState;
type MachineEvent = EventOf<typeof workerMachine.event>;
const processEvents = (eventsToProcess: MachineEvent[]) => {
const nextState = eventsToProcess.reduce(
workerMachine.transition,
currentState,
);
currentState = nextState;
return nextState;
};
const nextEvent = workerMachine.event.consumed({
id: '0',
data: Buffer.from('data'),
});
const resultingState = processEvents([nextEvent]);
if (currentState.is(state.Queued, state.Processing)) {
console.info('Job already consumed. Details:', currentState.data);
}Describe your state, then plug it into your state manager with available adapters. See examples in examples/.
- Redux Toolkit — slice & collection adapters (
simply-stated/redux-toolkit) · docs · examples - Zustand — (coming soon)
There are two types of state:
- Native state — what
state.*creators return and the machine operates on. It has one downside: it is not serialisable. - Plain state — serialisable version, but lacking some methods from the native state.
Therefore, there are toNativeState and toPlainState helpers available
for converting between the two.
import { toNativeState, toPlainState } from 'simply-stated';
const { state } = workerMachine;
let native = state.Idle();
native.is(state.Queued);
const plain = toPlainState(native);
JSON.stringify(plain); // safe
native = toNativeState(plain);Adapters for state managers utilise those helpers to achieve serialisability.