This article is a continuation of Part 1 in which I approach the problem in a more theoretical way. Here we will implement the State pattern in a simple way, using good practices. The focus will be on implementing a contact list screen (example from part 1).
Content
Remembering...
What is State?
The State pattern is a useful tool for developing systems, for managing what the system should react to and how it might behave. Its use makes it easier to maintain and control what the system should display or react to.
Because of its usefulness, it is also used by default by BLoC, one of the most widely used libraries in Flutter and a reference in state management.
What are we going to implement?
As discussed in part 1, we're going to use the example of a contact list screen, where it should display the user's customized name, the list of contacts, but also display different components depending on the current state (loading, error or loaded).
The logic is based on a finite state machine, where there is an initializer (initial state) that starts the flow of states.
Structuring
As the focus isn't on architecture, let's be straightforward and create 2 simple folders: one for repositories and another for the folder where we'll keep our UI, ViewModel and state. The suffixes of the files and the logic class (ViewModel/Controller/Store) are up to the programmer. The focus here is on the state. As a reactivity tool, I'm going to use ValueNotifier because it's easy and it's already included in the Flutter, but feel free to use MobX, GetX, ChangeNotifier or any other library.
The structure of our project will be as follows:
📂 lib
├─ 📄 main.dart - The central file of the project
├─ 📂 pages
│ └─ 📂 contacts_list
│ ├─ 📄 contacts_list.viewmodel.dart - ViewModel of our screen
│ ├─ 📄 contacts_list.page.dart - Central page of our UI
│ ├─ 📄 contacts_list.state.dart - Our state file
│ └─ 📂 widgets - Directory for storing components used on our page
│ ├─ 📂 repositories
│ ├─ 📄 contacts.repository.dart - Repository for accessing contact data
│ └─ 📄 user.repository.dart - Repository for accessing user data
│ └─ 📂 models
├─ 📄 contact.dart - Contact model
└─ 📄 user.dart - User model
Models
These will be simple, just for displaying information
// user.dart
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
// contact.dart
class Contact {
final int id;
final String name;
final String email;
final String phoneNumber;
Contact({
required this.id,
required this.name,
required this.email,
required this.phoneNumber,
});
}
Repositories
The repositories will be simple, with just an access function that should return that data, no registration, just mocked up data.
// contacts.repository.dart
import 'package:state_pattern/models/contact.dart';
class ContactsRepository {
Future<List<Contact>> fetchContacts() async {
// Simulate network delay
await Future.delayed(Duration(seconds: 2));
// Return a list of dummy contacts
return [
Contact(
id: 1,
name: 'Alice',
email: 'alice@example.com',
phoneNumber: '123-456-7890',
),
Contact(
id: 2,
name: 'Bob',
email: 'bob@example.com',
phoneNumber: '987-654-3210',
),
Contact(
id: 3,
name: 'Charlie',
email: 'charlie@example.com',
phoneNumber: '555-555-5555',
),
];
}
}
// user.repository.dart
import 'package:state_pattern/models/user.dart';
class UserRepository {
Future<User> fetchUser() async {
// Simulate network delay
await Future.delayed(Duration(seconds: 2));
// Return a dummy user
return User(id: 1, name: "John Doe", email: "john.doe@example.com");
}
}
I particularly like the Result pattern for returning data from data classes, but that's for another time
Our state
Declaring
Our state will be an empty class, declared with the word "sealed".
Sealed classes are implicitly abstract classes and are also limited to being extended and allow us to create switch/cases in an "exhaustive" way, similar to an enum. Let's get a better understanding of using switch/case.
// contacts_list.state.dart
sealed class ContactsListState {}
Defining the states of our screen
As defined by our diagram, we'll have 4 classes that will extend ContactsListState and what they would have in each one
// contacts_list.state.dart
class InitialState extends ContactsListState {}
class LoadingState extends ContactsListState {}
class ErrorState extends ContactsListState {
final String message;
ErrorState(this.message);
}
class LoadedState extends ContactsListState {
final List<Contact> contactList;
final User user;
LoadedState({required this.contactList, required this.user});
}
This way, we've defined a base type for our states and we're sure that it's a ContactsListState.
Regarding the state, I have 2 comments
- I recommend defining prefixes or suffixes in the context-related names (e.g. ContactListLoadingState, ContactListErrorState), I haven't used them now because I only have one class for each state.
- InitialState is not mandatory, I created it to define the beginning of our screen and for teaching purposes. If you prefer, you can remove it and start with the state you prefer.
when()
A very common function to use in classes of this type in BLoC because of its practicality is the when() function, which performs an action from each defined class. It will be implemented in the base state.
So, we have our final file
// contacts_list.state.dart
import 'package:state_pattern/models/contact.dart';
import 'package:state_pattern/models/user.dart';
sealed class ContactsListState {
T when<T>({
required T Function() initial,
required T Function() loading,
required T Function(String message) error,
required T Function(List<Contact> contactList, User user) loaded,
}) {
switch (this) {
case InitialState():
return initial();
case LoadingState():
return loading();
case ErrorState(:final message):
return error(message);
case LoadedState(:final contactList, :final user):
return loaded(contactList, user);
}
}
}
class InitialState extends ContactsListState {}
class LoadingState extends ContactsListState {}
class ErrorState extends ContactsListState {
final String message;
ErrorState(this.message);
}
class LoadedState extends ContactsListState {
final List<Contact> contactList;
final User user;
LoadedState({required this.contactList, required this.user});
}
Our ViewModel
Instantiating the ValueNotifier
As I said earlier, I chose to use ValueNotifier so as not to necessarily use any library. In fact, it's what I end up preferring to use in my day-to-day work, but if you prefer to use another class to define reactivity, that's up to you. :)
As mentioned before, its initial state will be InitialState and we can create it as final, since its base instance won't be changed, only the value inside ValueNotifier.
final ValueNotifier<ContactsListState> stateNotifier = ValueNotifier(InitialState());
Constructor and dependencies
It's a bit difficult not to talk about dependencies, but we're going to declare them and receive them through the constructor in a way that allows Dependency Injection in the future, if you want to use it as a base.
final ContactsRepository _contactsRepository;
final UserRepository _userRepository;
ContactsListViewModel({
required ContactsRepository contactsRepository,
required UserRepository userRepository,
}) : _contactsRepository = contactsRepository,
_userRepository = userRepository;
Data access functions
Before outputting our state, we also need the data first, so we'll access it using the getAllData() function
Future<void> getAllData() async {
// Emitting loading state
stateNotifier.value = LoadingState();
try {
final user = await _userRepository.fetchUser();
final contacts = await _contactsRepository.fetchContacts();
// Emitting LoadedState, as we have all the data needed
stateNotifier.value = LoadedState(user: user, contactList: contacts);
} catch (error) {
stateNotifier.value = ErrorState(error.toString());
}
}
From there we have our simple ViewModel ready, so let's move on to the screen.
Our screen
If we're going to display a specific widget for each state, then we'll implement a separate component each time.
As our state is being managed by the pattern in question, I'm going to focus on using StatelessWidgets, but that doesn't mean we shouldn't use StatefulWidgets
LoadedPageWidget
Our LoadedPageWidget will only be displayed when we have access to the user's name and contact list (LoadedState), so our component will receive these two pieces of information as parameters. It should display the user's name with a greeting, a list of users and have a button to update the information.
Let's create the list item
// contact_item_widget.dart
class ContactItemWidget extends StatelessWidget {
final String name;
final String email;
const ContactItemWidget({super.key, required this.name, required this.email});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(name),
subtitle: Text(email),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => print('Open whatsapp'),
icon: const Icon(Icons.edit),
),
IconButton(
onPressed: () => print('Open call number'),
icon: const Icon(Icons.phone),
),
],
),
);
}
}
Then our widget will look like this
// loaded_widget.dart
class LoadedWidget extends StatelessWidget {
final User user;
final List<Contact> contacts;
const LoadedWidget({super.key, required this.user, required this.contacts});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Text(
'Hello, ${user.name}, here are your contacts:',
style: Theme.of(context).textTheme.titleLarge,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
onPressed: () => onRefresh(),
icon: Icon(Icons.refresh),
),
],
),
),
Expanded(
child: ListView.separated(
separatorBuilder: (context, index) => Divider(
indent: 16,
endIndent: 16,
color: Colors.grey.shade300,
),
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
return ContactItemWidget(
name: contact.name,
email: contact.email,
);
},
),
),
],
),
),
);
}
}
And that will be the goal:
LoadingWidget
As we discussed in the last article, we're going to make a skeleton, i.e. a component that acts as a shadow of what will be displayed. To speed things up, we're going to use the Skeletonizer package just to be quicker at the moment, but Flutter already has native solutions. Let's take advantage and reuse our contact item list component
class LoadingWidget extends StatelessWidget {
const LoadingWidget({super.key});
@override
Widget build(BuildContext context) {
return Skeletonizer(
enabled: true,
child: Column(
children: [
Text(
'This is a text placeholder',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(onPressed: () {}, icon: Icon(Icons.refresh)),
],
),
),
Expanded(
child: ListView.separated(
separatorBuilder: (context, index) => Divider(
indent: 16,
endIndent: 16,
color: Colors.grey.shade300,
),
itemCount: 7,
itemBuilder: (context, index) {
final contact = Contact(
id: index,
name: 'Loading...',
email: 'Loading...',
phoneNumber: 'Loading...',
);
return ContactItemWidget(
name: contact.name,
email: contact.email,
);
},
),
),
],
),
);
}
}
And that's our goal:
ContactsErrorWidget
Our error component will be simple: an icon, a title and a message.
// contacts_error_widget
class ContactsErrorWidget extends StatelessWidget {
final String message;
const ContactsErrorWidget({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sentiment_dissatisfied,
color: Colors.red.shade700,
size: 128,
),
SizedBox(height: 16),
Text(
'Oh no! An error occurred:',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.red.shade700,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
message,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.red.shade400),
textAlign: TextAlign.center,
),
],
),
);
}
}
This is our result
Our main page
So after making our 3 main components, let's just put them together on our main screen, using the ValueListenableBuilder (since we're using ValueNotifier for our reactivity) to display them according to what we need.
So here's what we're going to do: instantiate the ViewModel, fire the data access function and return the widget from our screen.
class ContactsListPage extends StatelessWidget {
const ContactsListPage({super.key});
@override
Widget build(BuildContext context) {
// Creating an instance of the ViewModel
final viewModel = ContactsListViewModel(
contactsRepository: ContactsRepository(),
userRepository: UserRepository(),
);
// Start fetching data when the widget is built
viewModel.getAllData();
return Scaffold(
appBar: AppBar(title: const Text('Contacts List')),
body: ValueListenableBuilder(
valueListenable: viewModel.stateNotifier,
builder: (context, state, child) {
return state.when(
initial: () => LoadingWidget(),
loading: () => LoadingWidget(),
error: (errorMessage) => ContactsErrorWidget(message: errorMessage),
loaded: (contacts, user) =>
LoadedWidget(user: user, contacts: contacts),
);
},
),
);
}
}
"Ah, Pedro, but what about the InitialState?" It has its uses, but the main reason it exists is just to start the state management flow, so much so that it is replaced at the beginning of getAllData() and never used again. But you can create a specific widget for it if you like.
If you want to test how the error display works, just add a throw Exception("Example error")
to see how the UI reacts.
Conclusion
So that's it, we have a working feature using the State pattern.
The example we used is simplified into 3 base states, but we should also model and understand that these states should only reflect our need, and not make our need mold itself to each one. Like a form, for example, it can have different states, each with its own reaction, such as empty, filled in, under review, loading...
This pattern, like everything else in programming, is not something that will always work, but it is important to have the knowledge to know where and when to apply it and it has been very useful in developing my features.
I hope this article has been useful to you, thank you :)
The code is available on my Github: https://github.com/arcbueno/StatePatternFlutter
Top comments (0)