TypeState pattern with Flutter
2024-09-05 📖 9 min
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:
- My current job, as I dared to use Rust for the homework exercise, knowing it to be my biggest Rust project.
- 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 Actions | New State |
---|---|
Log In | Logged In |
Logged In Actions | New State |
---|---|
Log Out | Logged Out |
Lock Session | Locked Session |
Read Feed | |
Favorite Post | |
Share Post | |
Delete Post (if admin) | |
Edit User |
Locked Session Actions | New State |
---|---|
Log In | Logged In |
Log Out | Logged 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:
-
UserProvider as a StatefulWidget: The
UserProvider
is aStatefulWidget
that holds the state of the user session. It creates an instance ofUserProviderState
, which manages theUserSession
and listens for changes in the user state. -
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 callingsetState()
. -
InheritedWidget: The
UserProviderInherited
class extendsInheritedWidget
and serves as a mechanism to pass theUserProviderState
down the widget tree. It holds a reference to the current user state and the child widget that it wraps. -
Providing State: In the
build
method ofUserProviderState
, an instance ofUserProviderInherited
is created, passing the currentUserProviderState
and the child widget. This allows any descendant widgets to access the user state. -
Accessing State: The static method
maybeOf
inUserProvider
allows descendant widgets to retrieve the currentUserProviderState
usingdependOnInheritedWidgetOfExactType
. This establishes a dependency, meaning that if the user state changes, any widget that callsmaybeOf
will automatically rebuild to reflect the new state. -
Rebuilding on State Change: The
updateShouldNotify
method inUserProviderInherited
always returnstrue
, indicating that any change in theUserProviderState
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.