state4d 0.0.2
state4d: ^0.0.2 copied to clipboard
Simple state machine for Dart, including diagramming and async support
State4d #
A simple state machine library for Dart, ported from state4k.
Installation #
dependencies:
state4d: ^1.0.0
Concepts #
To model a state machine, you need:
- Entity - The object being modeled, which holds the current state
- States - The discrete states the entity can be in
- Events - Triggers that cause transitions between states
- Commands (optional) - Actions that execute when entering or exiting a state, which may generate events
Transitions occur in one of two ways:
- Receive a known out-of-band Event and transition to a new state
- Receive and process a Command, which will result in one of a discrete set of Events
The storage of the controlled entity is done entirely outside of State4d - the library is purely functional and returns new entity instances.
Example: Making Tea #
import 'package:result4d/result4d.dart';
import 'package:state4d/state4d.dart';
// The entity - tracks the state
class CupOfTea {
final TeaState state;
final String lastAction;
CupOfTea(this.state, this.lastAction);
CupOfTea copyWith({TeaState? state, String? lastAction}) =>
CupOfTea(state ?? this.state, lastAction ?? this.lastAction);
}
// The states
enum TeaState { getCup, boilingWater, steepingTea, checkForMilk, whiteTea, blackTea }
// Commands that can be triggered on state entry/exit
enum TeaCommands { doYouHaveMilk, removeBag }
// Events that trigger transitions
sealed class TeaEvent {}
class TurnOnKettle extends TeaEvent {}
class PourWater extends TeaEvent {}
class MilkPlease extends TeaEvent {}
class NoMilkPlease extends TeaEvent {}
class MilkIsFull extends TeaEvent {}
class MilkIsEmpty extends TeaEvent {}
// Lens to get/set state on the entity
final lens = StateIdLens<CupOfTea, TeaState>(
(entity) => entity.state,
(entity, state) => entity.copyWith(state: state),
);
// Command handler
Result<void, String> commands(CupOfTea entity, TeaCommands command) {
print('Issuing command $command');
return Success(null);
}
// Define the state machine
final teaStateMachine = StateMachine.create(commands, lens).states<TeaEvent>(
(state) => [
state(.getCup)
.transition(TurnOnKettle, .boilingWater),
state(.boilingWater)
.transition(PourWater, .steepingTea,
(event, entity) => entity.copyWith(lastAction: 'Waiting...')),
state(.steepingTea)
.onEnter(.doYouHaveMilk)
.onExit(.removeBag)
.transition(MilkPlease, .checkForMilk)
.transition(NoMilkPlease, .blackTea),
state(.checkForMilk)
.transition(MilkIsFull, .whiteTea)
.transition(MilkIsEmpty, .blackTea),
state(.blackTea),
],
);
Usage #
Event-based transitions #
final result = await teaStateMachine.transition(
CupOfTea(.getCup, '-'),
TurnOnKettle(),
);
switch (result) {
case Success(value: OK(entity: final tea)):
print('New state: ${tea.state}');
case Success(value: IllegalEvent()):
print('Event not valid for current state');
case Failure(reason: final error):
print('Error: $error');
}
Command-based transitions #
Commands are useful when the resulting event depends on external factors:
final result = await teaStateMachine.transitionCommand(
cupOfTea,
.doYouHaveMilk,
(entity) async {
// Check fridge, call API, etc.
final hasMilk = await checkFridge();
return hasMilk ? Success(MilkIsFull()) : Success(MilkIsEmpty());
},
);
Queued transitions with Runner #
For sequential transitions that should execute in order:
final runner = teaStateMachine.run(CupOfTea(.getCup, '-'));
runner.transition(TurnOnKettle());
runner.transition(PourWater());
final result = await runner.transition(MilkPlease());
// result contains the entity after all three transitions
Each call returns a Future - await any call to get the result up to that point:
final runner = teaStateMachine.run(initialEntity);
var result = await runner.transition(TurnOnKettle());
expect(result.value.state, .boilingWater);
result = await runner.transition(PourWater());
expect(result.value.state, .steepingTea);
The runner also supports transitionCommand:
final runner = teaStateMachine.run(initialEntity);
runner.transition(TurnOnKettle());
runner.transition(PourWater());
final result = await runner.transitionCommand(
.doYouHaveMilk,
(entity) async => Success(MilkIsFull()),
);
Listening for changes #
The runner supports a listener API compatible with Flutter's ChangeNotifier pattern:
final runner = teaStateMachine.run(CupOfTea(.getCup, '-'));
runner.addListener(() {
print('State changed to: ${runner.entity.state}');
});
await runner.transition(TurnOnKettle());
// Prints: State changed to: TeaState.boilingWater
runner.dispose(); // Clean up when done
Available methods:
addListener(void Function() listener)- Register a callback for entity changesremoveListener(void Function() listener)- Unregister a callbackhasListeners- Check if any listeners are registereddispose()- Stop notifications and clear all listeners
Flutter integration #
State4d has no Flutter dependency, but integrates easily with ValueListenableBuilder. Create a simple wrapper:
import 'package:flutter/foundation.dart';
import 'package:state4d/state4d.dart';
class StateMachineNotifier<E> extends ValueNotifier<E> {
final StateMachineRunner _runner;
StateMachineNotifier(StateMachineRunner runner)
: _runner = runner,
super(runner.entity as E) {
_runner.addListener(_onChanged);
}
void _onChanged() => value = _runner.entity as E;
@override
void dispose() {
_runner.removeListener(_onChanged);
super.dispose();
}
}
Use it with ValueListenableBuilder:
final runner = teaStateMachine.run(CupOfTea(.getCup, '-'));
final notifier = StateMachineNotifier<CupOfTea>(runner);
// In your widget tree
ValueListenableBuilder<CupOfTea>(
valueListenable: notifier,
builder: (context, tea, child) {
return Text('Current state: ${tea.state}');
},
)
// Trigger transitions from anywhere
await runner.transition(TurnOnKettle());
// Widget rebuilds automatically
Visualization #
Render the state machine using pluggable renderers. Mermaid and PlantUML are included:
print(teaStateMachine.renderUsing(renderMermaid));
print(teaStateMachine.renderUsing(renderPuml));
Output (Mermaid):
Write your own renderer by implementing StateMachineRenderer.
License #
This project is licensed under the Apache 2.0 License - see the LICENSE file for details.
Acknowledgments #
- Original state4k Kotlin library by the fork-handles team