TypeState pattern with Flutter

Intro

I've started a new job less than a year ago, and it's been pretty interesting. Most notably, the software development is mostly about using Flutter for creating Graphical User Interfaces, and thus relies on the Dart programming language. I've been considering Flutter for already a few years, and I actually did start to prototype something for the reverse engineering of a Bluetooth device as a proof of concept. However, I did not get as far as getting a proof of concept at all, and it's not really a problem.

I now have much more time to dedicate to Dart and Flutter, and I get paid for it. It turns out that I like how strict Dart can be, and how fast the Dart analyzer provides feedback when coding. Add to that the hot reload (or hot restart for some cases), the fast iteration time becomes very appreciated. Now, on to correctness. As you might know from some past articles, I've toyed with Rust, and even though I've not dedicated much time, it has brought me two things:

  1. My current job, as I dared to use Rust for the homework exercise, knowing it to be my biggest Rust project.
  2. Some more knowledge about functional programming, most notably the Type State design pattern, which is the idea of trying to enforce a proper API usage by exposing only the methods relevant to a given state of an object.

The TypeState Pattern for Driving the User Interface

While this article does not assume you know how to use Flutter, it will be helpful if you already understand the basics of Flutter or at least have some experience with reactive GUI programming, where changes in state drive changes in what is displayed.

The application I have built to experiment with this idea is quite simple and revolves around whether a user is logged in or out. There is no backend, nor any good data model to populate with mock data, as it's not the important part. The different states and actions available are as follows:

Logged Out ActionsNew State
Log InLogged In
Logged In ActionsNew State
Log OutLogged Out
Lock SessionLocked Session
Read Feed
Favorite Post
Share Post
Delete Post (if admin)
Edit User
Locked Session ActionsNew State
Log InLogged In
Log OutLogged Out
Edit User

As you can see from the tables, there are three states (Logged Out, Logged In, and Session Locked). There are a few actions that trigger state changes, some of which are shared between two different states. Some other actions do not trigger state changes, but they might be available only for users with a special role.

The good news is that it's possible to compose everything together by defining the states as sealed classes (similar to abstract classes) for inheriting some properties, and mixins for composing behaviors (actions) with the states that shall have these capabilities.

The UserState Class

The UserState class is the base class for defining the different user states possible (logged out, logged in, and locked). The base and child classes are pretty easy, knowing what we have:

/// Represents the base class for user states.
sealed class UserState with ChangeNotifier {}

/// Represents the logged-out state of a user.
class LoggedOut extends UserState with LoginMixin {}

/// Represents the logged-in state of a user with an unlocked session.
class LoggedInSessionUnlocked extends UserState
    with LogoutMixin, LockSessionMixin, UserProfileMixin {
  /* Hidden: some functions to fake a list of posts to interact with. */
}

/// Represents the logged-in state of a user with a locked session.
class LoggedInSessionLocked extends UserState
    with LoginMixin, LogoutMixin, UserProfileMixin {}

The base class is with ChangeNotifier to be able to notify the UI when changes happen. Each individual concrete user state is declared with CapabilityMixin, which implements the different actions possible in each state. The mixins are quite concise, too:

/// Mixin for logging in.
mixin LoginMixin on UserState {
  void login(UserSession session) => session.state = LoggedInSessionUnlocked();
}

/// Mixin for logging out.
mixin LogoutMixin on UserState {
  void logout(UserSession session) => session.state = LoggedOut();
}

/// Mixin for locking a session.
mixin LockSessionMixin on UserState {
  void lockSession(UserSession session) =>
      session.state = LoggedInSessionLocked();
}

/// Mixin for user info.
mixin UserProfileMixin on UserState {
  User get user => _user;
  User _user = const User(
    name: 'John Doe',
    email: 'john.doe@example.com',
    bio: 'Flutter enthusiast and developer.',
  );
  set user(User user) {
    _user = user;
    notifyListeners();
  }
}

There is nothing special here; calling login() creates a new LoggedInSessionUnlocked instance, a child of UserState. The setter for UserProfileMixin.user has an extra notifyListeners() to notify the UI that the data has changed, so the listeners for changes will rebuild.

However, you might have noticed a little but important detail: all the methods that change the state take a mysterious UserSession parameter and assign the new state to its state field. Here is the UserSession:

/// Class to manage user sessions.
class UserSession<S extends UserState> extends ChangeNotifier {
  UserSession(this._state) {
    _state.addListener(_onStateChanged);
  }

  S get state => _state;
  S _state;
  set state(S state) {
    _state.removeListener(_onStateChanged);
    _state = state;
    _state.addListener(_onStateChanged);

    // Forward outer state changes to the UI.
    notifyListeners();
  }

  // Forward inner state changes to the UI.
  void _onStateChanged() => notifyListeners();
}

Basically, it's a class that has a single field _state that has the generic type S, as long as it's a child of UserState. There is a tiny bit of glue code that makes this UserSession listen to all changes happening in the _state (thanks to the _state.addListener(_onStateChanged)), and removes the listener when changing to a new UserState.

If you have connected the dots, the UserSession contains a given UserState, and this UserState has methods to change the current state in UserSession. In other words, a given state gets its owner UserSession, and replaces itself in the _state field with the new UserState.

Creating a widget holding the UserState for the UI

Just to make a minimal project with zero dependencies, I implemented the demo using a plain InheritedWidget, without relying on any library for state management.

I've created a stateful UserProvider widget, that holds our UserState in its _session field. It only exposes _session.state, and rebuilds whenever it receives a notification that the inner _session.state has changed, thanks to the registered listener _onSessionChanged():

class UserProviderState extends State<UserProvider> {
  UserProviderState() {
    _session.addListener(_onSessionChanged);
  }

  final UserSession<UserState> _session = UserSession();

  UserState get state => _session.state;

  void _onSessionChanged() => setState(() {});

  @override
  Widget build(BuildContext context) => UserProviderInherited(
        userState: this,
        child: widget.child,
      );
}
Tap here to see the rest of the widget, and how InheritedWidget is used for the implementation.
@immutable
class UserProvider extends StatefulWidget {
  const UserProvider({required this.child, super.key});

  final Widget child;

  static UserProviderState? maybeOf(BuildContext context) => context
      .dependOnInheritedWidgetOfExactType<UserProviderInherited>()
      ?.userState;

  @override
  UserProviderState createState() => UserProviderState();
}

@immutable
class UserProviderInherited extends InheritedWidget {
  const UserProviderInherited({
    required this.userState,
    required super.child,
    super.key,
  });

  final UserProviderState userState;

  @override
  bool updateShouldNotify(UserProviderInherited oldWidget) => true;
}

The UserProvider widget utilizes the inherited widget architecture to manage and provide access to user state throughout the widget tree. Here's a brief explanation of how it works:

  1. UserProvider as a StatefulWidget: The UserProvider is a StatefulWidget that holds the state of the user session. It creates an instance of UserProviderState, which manages the UserSession and listens for changes in the user state.

  2. UserProviderState: This class contains the logic for managing the user session. It initializes a UserSession<UserState> instance and adds a listener to it. When the session changes, the _onSessionChanged method is called, which triggers a rebuild of the widget by calling setState().

  3. InheritedWidget: The UserProviderInherited class extends InheritedWidget and serves as a mechanism to pass the UserProviderState down the widget tree. It holds a reference to the current user state and the child widget that it wraps.

  4. Providing State: In the build method of UserProviderState, an instance of UserProviderInherited is created, passing the current UserProviderState and the child widget. This allows any descendant widgets to access the user state.

  5. Accessing State: The static method maybeOf in UserProvider allows descendant widgets to retrieve the current UserProviderState using dependOnInheritedWidgetOfExactType. This establishes a dependency, meaning that if the user state changes, any widget that calls maybeOf will automatically rebuild to reflect the new state.

  6. Rebuilding on State Change: The updateShouldNotify method in UserProviderInherited always returns true, indicating that any change in the UserProviderState should notify its dependents to rebuild. This ensures that any widget relying on the user state will receive updates when the state changes.

Using the UserState in the UI

Here is where the magic happens: the UI is not data-driven, meaning it is not driven by the value of the data. Instead, the UI is driven by the data type. Let's start from the beginning, with the main() entry point.

The SealedRouter

I created a SealedRouter widget that switches over all the possible types that can be taken by the userState variable, which is fetched from the InheritedWidget. It is not a real router, as it doesn't use Flutter's Navigator, but that is not relevant for this example app.

void main() => runApp(
      const MaterialApp( // The usual root-level widget.
        home: UserProvider( // The widget providing the UserState downwards.
          child: SealedRouter(), // The widget handling which page to show.
        ),
      ),
    );

@immutable
class SealedRouter extends StatelessWidget {
  const SealedRouter({super.key});

  @override
  Widget build(BuildContext context) {
    // Fetch the `UserState` (if available).
    final userState = UserProvider.maybeOf(context);
    return switch (userState?.state) {
      // First, the sealed class `extends`.
      LoggedInSessionUnlocked() => const LoggedInScreen(),
      LoggedOut() => const LoginScreen(),
      LoggedInSessionLocked() => const LoggedInScreen(),
      // Then the case where no state was found in the widget tree.
      null => const InvalidUserState.nullState(),
      // Last, the available mixins `with`.
      LoginMixin() => const InvalidUserState.invalidVariant(),
      LogoutMixin() => const InvalidUserState.invalidVariant(),
      LockSessionMixin() => const InvalidUserState.invalidVariant(),
      UserProfileMixin() => const InvalidUserState.invalidVariant(),
    };
  }
}

ℹ️ InvalidUserState is a widget I created to display an error placeholder.

Notice how the SealedRouter builds a different page depending on the effective type of the UserState instance? The switch expression requires exhaustive matching, and I usually prefer to list all possible cases manually rather than using a catch-all _ clause. This approach ensures that I do not overlook anything when adding a new state to my sealed class. Additionally, since all mixins encode behaviors (that is, available actions), they should control which buttons (interactions) are displayed. However, they are not relevant for selecting which page to build.

Now, let's see how the first screen works.

The LoginScreen

The login screen is quite basic, as it only displays a single button for login. However, the login() method is only available in a state that is with LoginMixin, and only in this case. Similarly, I use a switch expression to check that the userState type is indeed LoginMixin (as well as also being LoggedOut, as tested by the parent SealedRouter). This creates a new state variable restricted to the context of the corresponding switch arm, which guarantees that it has access to the login() method.

@immutable
class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final userState = UserProvider.maybeOf(context);
    return switch (userState) {
      UserProviderState(:final session, state: final LoginMixin state) =>
        Scaffold(
          appBar: AppBar(title: const Text('Login Screen')),
          body: Center(
            child: ElevatedButton(
              onPressed: () => state.login(session),
              child: const Text('Login'),
            ),
          ),
        ),
      null => const InvalidUserState.nullState(),
      _ => const InvalidUserState.invalidVariant(),
    };
  }
}

What about a logged-in user ?

The LoggedInScreen

The logged-in screen is much larger, and too extensive to paste the full code here. It features a navigation bar with a Feed tab displaying feed messages (including a small badge counting the total number of messages) and a Profile page. There are buttons in the application bar at the top of the screen: the first one is for logging in (when the user has locked their session), while the second one is for logging out.

Additionally, there is a row displaying a list of floating action buttons. In our case, the list can have either zero actions or one action for locking the session:

Scaffold(
  /* ... */
  floatingActionButton: Row(
    mainAxisAlignment: MainAxisAlignment.end,
    children: [
      if (state case final LockSessionMixin state)
        FloatingActionButton(
          onPressed: state.lockSession,
          tooltip: 'Lock session',
          child: const Icon(Icons.lock),
        ),
    ],
  ),
),

The action is added conditionally, only if the action of locking the session is available. If you remember the tables at the beginning of the article, a session can only be locked for the LoggedInSessionUnlocked state, which is the only one with LockSessionMixin.

Similarly, the actions in the application bar are added conditionally:

Scaffold(
  appBar: AppBar(
    title: const Text('Home Screen'),
    actions: [
      if (state case LoginMixin())
        IconButton(icon: const Icon(Icons.login), onPressed: state.login),
      if (state case LogoutMixin())
        IconButton(icon: const Icon(Icons.logout), onPressed: state.logout),
    ],
  ),
  /* ... */
),

The FeedPage

Similarly, the Feed page changes its behavior depending on whether the user session is locked or not:

switch (userState) {
  UserProviderState(state: LoggedInSessionUnlocked(:final posts)) =>
    Scaffold(
      appBar: AppBar(
        title: const Text(
          'Feed',
          style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.teal),
        ),
      ),
      body: ListView.builder(
        itemCount: posts.length,
        itemBuilder: (context, index) => FeedItem(posts[index]),
      ),
    ),
  UserProviderState(state: LoggedInSessionLocked()) => const Center(
      child: Text(
        'Feed is disabled because your session is locked. Log-in again to access your feed.',
        textAlign: TextAlign.center,
      ),
    ),
  null => const InvalidUserState.nullState(),
  _ => const InvalidUserState.invalidVariant(),
}

And the rest?

It's all same everywhere. If you look at the full repository, you will see that the actions on posts are implemented directly in the LoggedInSessionUnlocked rather than in a separate mixin. You could argue that it's unpure, and against what I presented in the article. However, the actions are available exclusively in this specific context ,rather than being shared across different states. So instead of premature abstraction, I went for pragmatism.

Conclusion

In this article, we explored the TypeState design pattern in Flutter, focusing on how it can enforce the correct use of a given API within an application. The pages displayed are based on the user's current state, while the buttons displayed are based of the effectively available actions for the current user state.

The repository has even an additional improvement, removing the need to pass the session parameter for all state changes.