How to implement State Machines and Statecharts in dart and Flutter

Sandro Maglione

Sandro Maglione

Mobile development

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: a String variable to keep track of the machine's current state
  • events: define transitions between states. We use a nested Map: 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 from Map we need to make sure that S and E implement the correct equality check.

You can use freezed or equatable to achieve this.

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.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.

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", the context 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 a Function that given the current context executes some side-effect and optionally returns an updated context

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.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.

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 default StreamController 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.

πŸ‘‹γƒ»Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.