Cleaner Flutter Vol. 10: State and UI
or: How I Learned to Stop Worrying and Love the Bloc
It’s amazing to think we started this series with ten volumes in mind and only the last two were focused on the app itself. That’s what you get when you talk about architecture (the right way) and split your projects in a maintainable and scalable way. But well, let’s talk about state and user interfaces.
In the previous volume, we talked about how to identify and inject dependencies in our apps. Our application’s dependencies will be repositories or data sources, but they are going to be used by the business logic components (blocs), and then the blocs are going to emit states that the user interface will use to render widgets.
Part of this article will be about state management using my two favorite alternatives: package:bloc
and package:riverpod
. I have an article about the latter at Riverpod: Rewriting Provider, not yet one for bloc
. I’ll try to write one soon but in the meantime, you can check this one made by Ana from Flutter Spain.
What to look for in a state manager
I want to start this discussion by explaining why I like bloc
and riverpod
, so this is an abstract of state management and can be used to evaluate other options when making this choice.
Predictability
First of all, I think code, in general, should be easy to comprehend, and that way it’s also predictable.
Someone asked me once what I meant with predictable code, to explain that I’m going to quote my teammate Jorge in his Are you saying that my code is boring? Thank you! article:
Producing boring code is the biggest compliment that an engineering team can receive; after all, “surprises” in a project tend to be not so good surprises. No one likes receiving a 2 a.m. emergency call, or spending 8 hours tracking down a bug that is almost impossible to reproduce and as engineers, we don’t like solving the same problem over and over again.
Predictable code is the one that is consistent and somewhat obvious. You know the structure and that allows you to know where things are without having to ask another engineer.
You can see this aspect in package:bloc
when you create a new Bloc
. The component is created aiming for a feature-driven architecture, so each feature has one Bloc
assigned to it. Additionally, you’re always going to have three files related to that Bloc
: bloc, event, and state.
💡 When you’re exploring a bloc directory, I recommend going through the files in this order: event, state, bloc. It’s an easy way to see the causes (events), the effects (states) they provoke, and what’s the relation between both (bloc).
On riverpod
land, I already recommended a logical structure for your business logic in my article about it, but there’s no official way to organize them as you may have functionality inside another type of Provider
that is not a business logic one like StateNotifierProvider
.
We can say bloc
is more predictable or at least the documentation is more explicit about this aspect.
Testability
Testing is a critical requirement for a project in my opinion. That makes the ease to test an invaluable detail to take into consideration when choosing a state management tool.
You can check more about testing on the article Flutter testing: A very good guide [10 Insights] from Very Good Ventures.
This is a broad topic to talk about, but I can mention some general ideas of what to search for when evaluating a state manager regarding testing.
Shouldn’t depend on lots of packages
If you are unit testing a layer of your architecture, you should have a few dependencies imported for testing it.
For bloc
, you have a dedicated test utility called package:bloc_test
, which can also be used in conjunction with package:mocktail
and used to mock blocs for widget testing.
For riverpod
, there is no testing utility to facilitate StateNotifier
testing, so it ends up being a common unit test and you could also use mocktail
to mock the component’s dependencies.
class MockSharedPreferencesRepository extends Mock
implements SharedPreferencesRepository {}
void main() {
late SharedPreferencesRepository preferencesRepository;
setUp(() {
preferencesRepository = MockSharedPreferencesRepository();
});
blocTest<PreferencesBloc, PreferencesState>(
'emits [PreferencesEmpty] when PreferencesCleared is added.',
setUp: () {
when(preferencesRepository.clearValues).thenAnswer((_) async {});
},
build: () => PreferencesBloc(repository: preferencesRepository),
act: (bloc) => bloc.add(PreferencesCleared()),
expect: () => const <PreferencesState>[PreferencesEmpty()],
);
}
Set up should be easy
As we mentioned in the previous volume, state management is a combination between dependency injection and reactive components. That means that to test these reactive components isolated we don’t need any dependency injection other than mocking their dependencies. And when using them to test UI, we should inject them with our DI mechanism easily without additional setup that can be repetitive and frustrating.
void main() {
group('ProfileBloc', () {
blocTest<ProfileBloc, ProfileState>(
'emits [ProfileLoaded] when ProfileAccountAdded is added.',
build: () => ProfileBloc(),
act: (bloc) => bloc.add(ProfileAccountAdded(user)),
expect: () => <ProfileState>[
ProfileLoaded(
current: user,
accounts: [user],
),
],
);
});
}
Both bloc
and riverpod
are pretty easy to test, but I’d love a utility like bloc_test
for state_notifier
(not exactly a riverpod
related request).
100% coverage is achievable
I’m not saying that your project must have 100% test coverage, but for me it’s a useful metric to at least determine that your project has a decent amount of tests and can also help you to see which parts of your app are not being triggered, helping you to evaluate if it’s critical to test it.
That said, I do think that if you strive for that one-hundred mark, your packages should help you do so by exposing a clean and comprehensible API that won’t stand in your way to get it.
Yes, both picks are good with that.
Unique benefits
I have to say I like these two packages because they both have something unique in their way to do state management. I love how bloc
has evolved to simplify the Stream
API and reactive concepts from package:rxdart
. Also, I’m a big fan of how riverpod
has introduced an innovative approach to dependency injection that facilitates communication between components.
I think both are robust solutions to the problems that may appear when developing apps, but I got to give bloc an advantage in this regard that comes from being built on top of the Stream
API: package:bloc_concurrency
.
This is the most recent addition to the bloc library and has improved the ways to handle events in our blocs by introducing EventTransformers
that can apply useful behaviors to process these events.
FileBlocNew({required this.fileRepo}) : super(FileState.initial()) {
on<LoadFiles>(
(event, emit) => loadFiles(event, emit.call),
// If a LoadFiles event is added while a previous LoadFiles event
// is still processing, the previous LoadFiles event will be cancelled.
// This is beneficial since we only care about the latest result, and
// avoids waiting on unnecessary and possibly out of date results.
transformer: restartable(),
);
on<DeleteFile>(
(event, emit) => deleteFile(event, emit.call),
// The default behavior of Bloc 7.2+ is to process events in parallel,
// so we could have left out the transformer here.
//
// However, it is worth leaving here to indicate that the writes here
// don't mutate the states of other files, so we can benefit from
// concurrent processing.
//
// If the order of writes is important, we could have used sequential()
// here.
transformer: concurrent(),
);
}
That snippet was taken from the bloc_concurrency_demos repository. More about bloc_concurrency
in my teammate Joanna’s deep dive article How to use Bloc with streams and concurrency.
Verdict
You can’t go wrong with these two state managers.
State management itself becomes a subjective matter because everyone has different needs. But if you’re thinking about a definitive tool and you know that your app can become a behemoth one day, I think the easiest way is to go with the bloc library.
It’s been around for quite some time now and has proven to be the best way to scale apps. Not to mention that is a library, conjunction of packages that provide pretty sweet functionality that most apps will need if they grow enough.
I still like riverpod
as an alternative, and you can use both with my friend Frank’s package:riverbloc
, which takes the reactive components and compatibility with the rest of the packages from bloc
and the dependency injection used with riverpod
to make a cool team-up.
Building widgets based on state
I wanted to add this small section to this article because I think sometimes is underestimated, and that’s how to structure our widgets to handle state properly.
For bloc
, we can use flutter_bloc
widgets to inject blocs into the widget tree and then render other widgets or even trigger actions based on their state. We’re going to focus on how to structure Bloc
injection in our widgets as for riverpod
we have a ProviderScope
that allows us to use Providers
from every widget that is below it on the widget tree.
Page-View pattern
There’s a common issue that gets open every once in a while at the Bloc GitHub repository that has to do with calling a bloc that is not on the context that is being used.
To avoid getting this issue as much as possible, there is the Page-View pattern that uses a Page widget to inject the Bloc
via BlocProvider
, and then use it on a View widget with a new BuildContext
.
class SignUpPage extends StatelessWidget {
const SignUpPage({Key? key}) : super(key: key);
static Page page() => const MaterialPage<void>(child: SignUpPage());
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => SignUpBloc(),
child: const SignUpView(),
);
}
}
class SignUpView extends StatelessWidget {
const SignUpView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocListener<SignUpBloc, SignUpState>(
listener: (context, state) {
if (state.complete) {
Navigator.of(context).pushReplacement<void, void>(
HomePage.page(),
);
}
},
child: SignUpForm(),
);
}
}
This pattern makes even more sense when you test your widgets. When testing SignUpPage
from the snippet above, you only have to pump your page and check if the view is rendered.
void main() {
testWidgets('renders SignUpView', (tester) async {
await tester.pumpApp(const SignUpPage());
expect(find.byType(SignUpView), findsOneWidget);
});
}
Then, to test the view, you only need to inject a bloc and mock its state to comply with your test’s expectations.
class MockSignUpBloc extends MockBloc<SignUpEvent, SignUpState>
implements SignUpBloc {}
void main() {
testWidgets(
'navigates to HomePage when SignUpState is complete',
(tester) async {
const user = User(
email: 'me@marcossevilla.dev',
name: 'Marcos',
biography: "I'm a software engineer",
pin: '0123',
);
final signUpBloc = MockSignUpBloc();
whenListen(
signUpBloc,
Stream.fromIterable([SignUpState(user: user, complete: true)]),
initialState: SignUpState(user: user),
);
await tester.pumpApp(
BlocProvider<SignUpBloc>.value(
value: signUpBloc,
child: const SignUpView(),
),
);
await tester.pumpAndSettle();
expect(find.byType(HomePage), findsOneWidget);
},
);
}
💡 This pattern forces us to scope our blocs so that they are created right above the “branch” of the widget tree that consumes their state. For example, we have the SignUpBloc scoped to the sign-up feature, not globally created for every part of the tree.
Widget reusability
At last, I want to talk about how to reuse widgets. I’ll probably create a more in-depth article about widget structure and maybe throw some atomic design in the mix, but in the meantime, I’ll give you this short explanation on how to create widgets based on state.
The first question that should come to our minds before creating a widget should be “Am I going to reuse this widget in another part of our app?”
Once we answered that, we can define which widgets are going to be sent to the widgets folder on our feature or to our app component package, if you like to extract the components you reuse in various features.
Then, I suggest you apply the same principle of the bloc injection and maintain your state as the closest parent to the widget that is going to consume it. For example:
class CounterView extends StatelessWidget {
const CounterView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder(
builder: (context, count) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Text(
'$count',
style: Theme.of(context).textTheme.headline1,
),
),
floatingActionButton: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => context.read<CounterBloc>().add(Increment()),
),
const SizedBox(height: 4),
FloatingActionButton(
child: const Icon(Icons.remove),
onPressed: () => context.read<CounterBloc>().add(Decrement()),
),
const SizedBox(height: 4),
FloatingActionButton(
child: const Icon(Icons.brightness_6),
onPressed: () => context.read<ThemeCubit>().toggleTheme(),
),
],
),
);
},
);
}
}
This widget has a BlocBuilder
as root widget and just consumes the state on the Text
widget located at the body of the Scaffold
. Neither the AppBar
nor the FloatingActionButton
consumes this state, so it can be better organized like this:
class CounterView extends StatelessWidget {
const CounterView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text(
'$count',
style: Theme.of(context).textTheme.headline1,
);
},
),
),
floatingActionButton: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
// buttons...
],
),
);
}
}
In case we want additional functionality to our Text
widget, we can do two things. We’re going to reset the counter if we tap on an IconButton
right below the count.
class CountDisplay extends StatelessWidget {
const CountDisplay({
Key? key,
required this.count,
}) : super(key: key);
final int count;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$count',
style: Theme.of(context).textTheme.headline1,
),
IconButton(
icon: const Icon(Icons.restore),
onPressed: () => context.read<CounterBloc>().add(Reset()),
),
],
);
}
}
But if we want to reuse this widget with the same IconButton
but different functionality, I’d suggest decoupling it from the CounterBloc
reset event and parametrize that interaction this way:
class CountDisplay extends StatelessWidget {
const CountDisplay({
Key? key,
required this.count,
this.onRestore,
}) : super(key: key);
final int count;
final VoidCallback? onRestore;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$count',
style: Theme.of(context).textTheme.headline1,
),
IconButton(
icon: const Icon(Icons.restore),
onPressed: onRestore,
),
],
);
}
}
Wrap-up
Thanks for completing this series with me! I appreciate all the love people have been giving to these articles, from comments to questions of when the next one was coming out.
I know the last two were a bit late and maybe they won’t feel like part of the same series as the others because my opinions about architecture have changed over this past year, but CleanScope is still a strong option if you want to follow clean architecture as close as possible.
I’ll write another article about architecture and what CleanScope taught me about it down the road, explaining where it succeed and where there was room for improvement.
All I can say is, keep on learning about architecture, it’s a broad topic that requires knowledge on many subtopics as you saw during this series. Remember that if you follow the SOLID principles and the dependency rule, you are most likely to have a robust architecture!
Thanks to my friend Elian for his help on volumes 2 and 5, he put a lot of effort into those and I’m humbled to have him as a writing partner for the whole Cleaner Flutter initiative!