Learn how to implement type-safe State Machines and Statecharts in dart and Flutter:
- How to model a state machine in dart
- How to transition between states
- How to extend a state machine to add actions (Statechart)
- How to stream state changes (state management)
Modeling a State Machine in dart
State machines capture all the states, events and transitions between them.
Using state machines makes it easier to find impossible states and spot undesirable transitions.
Transitions are only allowed from valid states.
We define a state machine using an abstract class Machine
.
It has 2 key features:
state
: aString
variable to keep track of the machine's current stateevents
: define transitions between states. We use a nestedMap
: for each state (Map<String,
) defines a map of events (Map<String, Map<String,
) and the next state (Map<String, Map<String, String>>
)
abstract class Machine {
Machine(this.state, this.events);
/// π From π Event π To
final Map<String, Map<String, String>> events;
String state;
}
A machine is then implemented by extending Machine
.
We set "Paused"
as the initial state and define a transition to the "Playing"
state when the "play"
event occurs:
class MyMachine extends Machine {
MyMachine()
: super(
"Paused", /// π Initial state
{
"Paused": {
'play': "Playing", /// π Transition
},
},
);
}
Finally, we implement a add
method that takes an event
tries to find the next state based on the current state and the provided event.
If a valid next state exists, add updates the machine's internal state to reflect the change:
abstract class Machine {
Machine(this.state, this.events);
final Map<String, Map<String, String>> events;
String state;
void add(String event) {
final nextState = events[state]?[event];
if (nextState == null) return;
state = nextState;
}
}
We can then create an instance of MyMachine
and send events to update the current state.
The key feature of state machines is that no state change is allowed when an event is sent from an invalid state
final myMachine = MyMachine();
myMachine.add("play"); /// π Transition to new `state`
Generic types and type-safety
There is an issue with the previous implementation of Machine
. You can see it here below:
class MyMachine extends Machine {
MyMachine()
: super(
"Paused",
{
"Pasued": {
'play': "Playing",
},
},
);
}
Found it? How do you spell "Paused"
? "Pasued"
?
The state transition has a typo! π€―
These are the worst type of errors. Since we have no type-check and no code to report errors, this typo will remain unnoticed until everything breaks.
Then good luck finding the culprit ππΌββοΈ
This is where generic types can save us!
We introduce a new generic type parameter S
for states:
abstract class Machine<S> {
Machine(this.state, this.events);
final Map<S, Map<String, S>> events;
S state;
}
This is not enough. We want type safety also for events, otherwise:
final myMachine = MyMachine();
myMachine.add("plaay"); /// Erm... π
We add another type parameter E
:
abstract class Machine<S, E> {
Machine(this.state, this.events);
final Map<S, Map<E, S>> events;
S state;
}
Now we can properly type the event
parameter in the add
method to completely remove any issue with possible typos:
abstract class Machine<S, E> {
Machine(this.state, this.events);
final Map<S, Map<E, S>> events;
S state;
void add(E event) {
final nextState = events[state]?[event];
if (nextState == null) return;
state = nextState;
}
}
π‘ Important: Since in the
add
method we are checking for equality when extracting the next state fromMap
we need to make sure thatS
andE
implement the correct equality check.
Dart sealed class for type-safety
The next level of type safety comes with sealed
classes.
We enumerate all possible states and events using sealed
:
sealed class MyState {}
class Paused extends MyState {}
class Playing extends MyState {}
sealed class MyEvent {}
class Play extends MyEvent {}
sealed
classes has been introduced in Dart 3.0.If you are interested in learning more about them you can read Records and Pattern Matching in dart - Complete Guide
We then use MyState
and MyEvent
as type parameters when creating MyMachine
:
class MyMachine extends Machine<MyState, MyEvent>
In this way all states and all events are typed correctly. Any typo or incorrect use of a class becomes impossible at compile-time, since dart will report any issue ahead of time:
class MyMachine extends Machine<MyState, MyEvent> {
MyMachine()
: super(
Paused(),
{
Paused(): {
Play(): Playing(),
},
},
);
}
void main() {
final myMachine = MyMachine();
myMachine.add(Play());
}
There is more π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
Statecharts in dart
Statecharts extend traditional finite state machines to model more complex logic.
Statecharts add extra features not available in ordinary state machines: hierarchy, concurrency and communication.
We are going to extend Machine
by adding context and actions for states and events.
Machine context
We extend the machine by adding a context.
Context represent some internal state that can be updated from states and events after a transition (side-effects).
An example of context can be a form: while the
state
may be"Editing"
or"Sending"
, thecontext
is"username"
,"email"
,"password"
Since context can be any value we add it as another generic type C
:
abstract class Machine<C, S, E> {
Machine(this.context, this.state, this.events);
final Map<S, Map<E, S>> events;
S state;
C context;
}
Event action
In a state chart any event can trigger an action
(also called effect) that executes some code or/and updates context
.
An
EventAction
represents aFunction
that given the currentcontext
executes some side-effect and optionally returns an updatedcontext
typedef EventAction<Context> = Context? Function(Context ctx)?;
An example of event action is playing an audio: when the "play"
event is sent the machine transitions from the Paused
to the Playing
state and an action will start the audio.
We want all events in the machine to have a possible action
.
We achieve this by defining an abstract
class Event
("contract"):
typedef EventAction<Context> = Context? Function(Context ctx)?;
abstract class Event<Context> {
final String name;
final EventAction<Context> action;
const Event(this.name, {this.action});
}
We then require all events to extend Event
:
abstract class Machine<C, S, E extends Event<C>> {
Machine(this.context, this.state, this.events);
final Map<S, Map<E, S>> events;
S state;
C context;
}
State entry and exit actions
Actions can be triggered also when entering or exiting a state.
An example here may is entering a Reset
state: on enter we can trigger an action that updates context
to reset it to its initial value.
We implement entry and exit actions in the same way as events, by defining a new abstract
class State
:
typedef StateAction<Context> = Context? Function(Context ctx)?;
abstract class State<Context> {
const State({this.onEntry, this.onExit});
final StateAction<Context> onEntry;
final StateAction<Context> onExit;
}
We then require all states to extend State
:
abstract class Machine<C, S extends State<C>, E extends Event<C>> {
Machine(this.context, this.state, this.events);
final Map<S, Map<E, S>> events;
S state;
C context;
}
Actions in machine transitions
The final step is executing the actions from the add
method.
We check if an action is defined and we trigger it, each time updating the current context.
Actions are executed in order: first the exit action, then the event action, and finally the entry action.
abstract class Machine<C, S extends State<C>, E extends Event<C>> {
Machine(this.context, this.state, this.events);
final Map<S, Map<E, S>> events;
S state;
C context;
void add(E event) {
final nextState = events[state]?[event];
if (nextState == null) return;
/// Apply `exit` action for previous state
final exitContext = state.onExit?.call(context) ?? context;
context = exitContext;
/// Apply `event` action
final action = event.action;
final actionContext = action != null ? (action(context) ?? context) : context;
context = actionContext;
/// Apply `entry` action for upcoming state
final entryContext = nextState.onEntry?.call(context) ?? context;
context = entryContext;
state = nextState;
}
}
There is more π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
Streaming new states
Inside Machine
states can change at any time in response to events.
We want to allow some part of the code to listen for state changes and react to them.
This is how state management works: some part of the code updates the state, and we need to notify all listeners of this change
We can achieve this in dart using Stream
.
dart:async
provides a StreamController
class that allows to broadcast state changes to multiple listeners. We therefore add an internal _stateController
inside Machine
:
abstract class Machine<C, S extends State<C>, E extends Event<C>> {
Machine(this.context, this.state, this.events);
final _stateController = StreamController<S>.broadcast();
final Map<S, Map<E, S>> events;
S state;
C context;
}
Notice: We use the
.broadcast()
constructor to allow for multiple listeners, since by defaultStreamController
allows a single listener.
We then expose the Stream
of states from the controller (_stateController.stream
):
abstract class Machine<C, S extends State<C>, E extends Event<C>> {
Machine(this.context, this.state, this.events);
final _stateController = StreamController<S>.broadcast();
Stream<S> get streamState => _stateController.stream;
final Map<S, Map<E, S>> events;
S state;
C context;
}
It is also important to define a close
method that allows to close the stream when the machine is no longer used:
abstract class Machine<C, S extends State<C>, E extends Event<C>> {
Machine(this.context, this.state, this.events);
final _stateController = StreamController<S>.broadcast();
Stream<S> get streamState => _stateController.stream;
final Map<S, Map<E, S>> events;
S state;
C context;
Future<void> close() async {
await _stateController.close();
}
}
Adding new states to the Stream
The last step is adding new states to the Stream
by calling add
from _stateController
:
abstract class Machine<C, S extends State<C>, E extends Event<C>> {
Machine(this.context, this.state, this.events);
final _stateController = StreamController<S>.broadcast();
Stream<S> get streamState => _stateController.stream;
final Map<S, Map<E, S>> events;
S state;
C context;
Future<void> close() async {
await _stateController.close();
}
void add(E event) {
if (_stateController.isClosed) {
throw StateError('Cannot emit new states after calling close');
}
final nextState = events[state]?[event];
if (nextState == null) return;
/// Apply `exit` action for previous state
final exitContext = state.onExit?.call(context) ?? context;
context = exitContext;
/// Apply `event` action
final action = event.action;
final actionContext = action != null ? (action(context) ?? context) : context;
context = actionContext;
/// Apply `entry` action for upcoming state
final entryContext = nextState.onEntry?.call(context) ?? context;
context = entryContext;
_stateController.add(nextState);
state = nextState;
}
}
In this way any part of our code can subscribe to state changes by calling listen
on streamState
:
final myMachine = MyMachine();
myMachine.streamState.listen((state) {
print("New state: $state");
});
/// Remember to call `myMachine.close()` later on in the code ππΌββοΈ
This implementation is at the core of every state management solution in Flutter: some class contains the state and manages state updates by streaming changes
This is it!
Our Machine
implementation has all the features necessary to model state machines and state chart in dart.
We can then extend this solution for example by providing a Stream
also for context changes or by integrating Machine
with Flutter and widgets.
If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below π
Thanks for reading.