Photo by Element5 Digital on Unsplash
Simple Flutter Patterns in Dart 3
Dealing with Side-Effects in Flutter
Thanks to Lucas Reis for his excellent article on simple react patterns which was a major influence for this article.
I’ve been writing applications using component-driven frameworks like React & Flutter for a while now. Along the way, I’ve noticed that some patterns tend to repeat themselves. In 2019, I wrote an article in which I reviewed simple Flutter patterns that I have written regularly to handle side effects. Let's revisit this topic using the latest version of Dart.
For these examples, I’ll use the very simple method fetchPost
that fetches a post from JSONPlaceholder and deserializes it into a Post
object.
One thing to note is that each of these examples could be refactored to use FutureBuilder
, but I avoid that for the sake of aiding my explanations.
The Vanilla or Mixed Pattern
I am more proficient as a top-down developer: I start with the high-level concept of what I need to build. I apply areas of code reuse and abstraction as I identify the need for them rather than trying to build from the bottom up. I think of it as KISS (keep it simple stupid) when I can and DRY (don’t repeat yourself) when I must.
95% of the time when I start building a widget it’s with the Vanilla pattern where the logic and the view are colocated in the widget.
class PostVanillaScreen extends StatefulWidget {
const PostVanillaScreen({super.key});
@override
State<PostVanillaScreen> createState() => _PostVanillaScreenState();
}
class _PostVanillaScreenState extends State<PostVanillaScreen> {
PostResult result = const PostResult.loading();
@override
void initState() {
super.initState();
fetchPost().then((post) {
setState(() {
result = PostResult.data(post);
});
}).catchError((Object err) {
setState(() {
result = PostResult.error(err);
});
});
}
@override
Widget build(BuildContext context) {
return Center(
child: switch (result) {
PostResultLoading() => const Text('Loading...'),
PostResultError() => const Text("I'm sorry! Please try again."),
PostResultData(:final post) => Text(post.title),
},
);
}
}
The previous code does the following:
Define a StatefulWidget called
PostVanillaScreen
.Define an
initState
lifecycle method that fetches a post from a remote source.Render UI based on the result (specifically
PostResult
) of fetching thePost
.
This is a vanilla Flutter widget that you might write after just reading the docs. The main benefit of colocating code in this way is that it is simple and self-contained. Plugged in anywhere in your application, it will fetch and render data.
On the flip side, writing widgets in this manner can quickly become a cognitive burden. What happens when there is complex styling, derived state, or more advanced branching scenarios beyond the network? What about if I want to unit test the view without fetching the data every time or without mocking requests? It’s just not possible.
In this example, the logic and the view are intertwined in one widget, hence referring to it as a mixed pattern. A better way to build components that are more testable and maintainable is to separate the logic from the view. That’s where the next pattern comes in.
The Container/View Pattern
The Container/View pattern is also known as the Container-Presentational, Smart-Dumb, or Screen-Component pattern. The core idea is that how data is retrieved (Container) is isolated from how it is presented (View). Let's look at how the example from the Vanilla pattern looks when refactored to the Container/View pattern.
class PostContainerViewScreen extends StatefulWidget {
const PostContainerViewScreen({super.key});
@override
State<PostContainerViewScreen> createState() =>
_PostContainerViewScreenState();
}
class _PostContainerViewScreenState extends State<PostContainerViewScreen> {
PostResult result = const PostResult.loading();
@override
void initState() {
super.initState();
fetchPost().then((post) {
setState(() {
result = PostResult.data(post);
});
}).catchError((Object err) {
setState(() {
result = PostResult.error(err);
});
});
}
@override
Widget build(BuildContext context) {
return PostView(result: result);
}
}
class PostView extends StatelessWidget {
const PostView({
super.key,
required this.result,
});
final PostResult result;
@override
Widget build(BuildContext context) {
return Center(
child: switch (result) {
PostResultLoading() => const Text('Loading...'),
PostResultError() => const Text("I'm sorry! Please try again."),
PostResultData(:final post) => Text(post.title),
},
);
}
}
The refactored code does the following:
Define a StatefulWidget called
PostContainerViewScreen
that loads thePost
data and forwards it toPostView
.Define a
PostView
widget that builds the UI based on thePostResult
from the Container widget.
Hopefully it is apparent that there is very little variation between this code and the code from the Vanilla pattern. The main difference is the isolation created by separating the concerns of loading and presenting into two widgets. The PostContainerViewScreen
Container widget handles the logic of fetching a post while the PostView
widget handles branching between the different UI that is displayed depending on whether the post has loaded.
Now that we’ve separated the business logic from the view, testing and styling the UI is trivial. We no longer need to worry about mocking. This pattern is also very easy to build upon: breaking down the UI into several widgets as its complexity grows is trivial. We could even extract the switch
logic that defines what to render into its own widget. That leads us to our next pattern which is a variant of the Container/View pattern.
Check out Dan Abramov's outstanding article on this pattern.
Variation: The Container/Branch/View Pattern
class PostContainerViewBranchScreen extends StatefulWidget {
const PostContainerViewBranchScreen({super.key});
@override
State<PostContainerViewBranchScreen> createState() =>
_PostContainerViewBranchScreenState();
}
class _PostContainerViewBranchScreenState
extends State<PostContainerViewBranchScreen> {
PostResult result = const PostResult.loading();
@override
void initState() {
super.initState();
fetchPost().then((post) {
setState(() {
result = PostResult.data(post);
});
}).catchError((Object err) {
setState(() {
result = PostResult.error(err);
});
});
}
@override
Widget build(BuildContext context) {
return PostView(result: result);
}
}
class PostView extends StatelessWidget {
const PostView({
super.key,
required this.result,
});
final PostResult result;
@override
Widget build(BuildContext context) {
return Center(
child: switch (result) {
PostResultLoading() => const PostLoadingView(),
PostResultError(:final error) => PostErrorView(error),
PostResultData(:final post) => PostDataView(post: post),
},
);
}
}
class PostLoadingView extends StatelessWidget {
@visibleForTesting
const PostLoadingView({super.key});
@override
Widget build(BuildContext context) {
return const Text('Loading...');
}
}
class PostErrorView extends StatelessWidget {
@visibleForTesting
const PostErrorView(
this.error, {
super.key,
});
final Object error;
@override
Widget build(BuildContext context) {
return const Text("I'm sorry! Please try again.");
}
}
class PostDataView extends StatelessWidget {
@visibleForTesting
const PostDataView({
super.key,
required this.post,
});
final Post post;
@override
Widget build(BuildContext context) {
return Center(
child: Text(post.title),
);
}
}
In this example, we have modified the PostView
so that the UI is separated by state (loading, error, and success). These individual views are even more isolated, which extends our testing capabilities and development workflow. Deciding how much to break up the view should be done on a case-by-case basis, but the rule of thumb should always be KISS first. If a widget is no longer simple and straightforward, it is probably a good candidate for refactoring.
A minor callout is the use of the @visibleForTesting
decorator, which allows us to specify that a widget is only allowed to be used within the context of this file unless it is referenced in a test. This allows us to separate our UI into smaller, more testable widgets without losing their intent.
There is one area where this pattern falls short: when you’d like to reuse logic but give the parent widget control over the view that is built for the user. Let’s look at my personal favorite pattern for dealing with this scenario.
Builder Props
class PostFutureBuilder extends StatefulWidget {
const PostFutureBuilder({
super.key,
required this.builder,
});
final Widget Function(BuildContext, PostResult) builder;
@override
State<PostFutureBuilder> createState() => _PostFutureBuilderState();
}
class _PostFutureBuilderState extends State<PostFutureBuilder> {
PostResult result = const PostResult.loading();
@override
void initState() {
super.initState();
fetchPost().then((post) {
setState(() {
result = PostResult.data(post);
});
}).catchError((Object err) {
setState(() {
result = PostResult.error(err);
});
});
}
@override
Widget build(BuildContext context) {
return widget.builder(context, result);
}
}
class PostBuilderScreen extends StatelessWidget {
const PostBuilderScreen({super.key});
@override
Widget build(BuildContext context) {
return PostFutureBuilder(
builder: (context, result) {
return Center(
child: switch (result) {
PostResultLoading() => const Text('Loading...'),
PostResultError() => const Text("I'm sorry! Please try again."),
PostResultData(:final post) => Text(post.title),
},
);
},
);
}
}
This pattern may look intimidating at first, but just like our previous examples, we are mostly relocating code with:
A StatefulWidget called
PostFutureBuilder
that loads thePost
data and forwards it to a function that will return UI upon execution.Define a
PostBuilderScreen
widget that uses thePostFutureBuilder
, providing it with a builder function that branches the UI based on thePostResult
.
The important thing to note about this pattern is that the business logic is now completely decoupled from our view and passed dynamically to the builder
function. This allows other areas of our application to consume the PostFutureBuilder
while providing their own UI. This is similar to how FutureBuilder
works when you pass it a Future.
The inversion of control here is powerful. It is entirely possible that we’d want to reuse the logic, but display a different view in different areas of the application, and this pattern is perfect for that. We could also do interesting things, like injecting dependencies as props to drive the logic.
Like the Container/View pattern, this pattern also has a branching-based variation.
Variation: Branching Builder Props
class PostFutureBuilder extends StatefulWidget {
const PostFutureBuilder({
super.key,
required this.builder,
});
final Widget Function(BuildContext, PostResult) builder;
@override
State<PostFutureBuilder> createState() => _PostFutureBuilderState();
}
class _PostFutureBuilderState extends State<PostFutureBuilder> {
PostResult result = const PostResult.loading();
@override
void initState() {
super.initState();
fetchPost().then((json) {
setState(() {
result = PostResult.data(json);
});
}).catchError((Object err) {
setState(() {
result = PostResult.error(err);
});
});
}
@override
Widget build(BuildContext context) {
return widget.builder(context, result);
}
}
class PostBuilderBranchScreen extends StatelessWidget {
const PostBuilderBranchScreen({super.key});
@override
Widget build(BuildContext context) {
return PostFutureBuilder(
builder: (context, result) {
return switch (result) {
PostResultLoading() => const PostLoadingView(),
PostResultError(:final error) => PostErrorView(error),
PostResultData(:final post) => PostDataView(post: post),
};
},
);
}
}
class PostLoadingView extends StatelessWidget {
@visibleForTesting
const PostLoadingView({super.key});
@override
Widget build(BuildContext context) {
return const Text('Loading...');
}
}
class PostErrorView extends StatelessWidget {
@visibleForTesting
const PostErrorView(
this.error, {
super.key,
});
final Object error;
@override
Widget build(BuildContext context) {
return const Text("I'm sorry! Please try again.");
}
}
class PostDataView extends StatelessWidget {
@visibleForTesting
const PostDataView({
super.key,
required this.post,
});
final Post post;
@override
Widget build(BuildContext context) {
return Center(
child: Text(post.title),
);
}
}
Note that the previous code closely resembles the Builder variant, but just like with the Container/View variant we now explicitly separate individual views into three separate builder prop functions so that we have the same benefits of testability. And that’s it.
What if the side effects are costly?
There are times when the logic provided by a builder prop leads to costly code. Assuming that our PostFutureBuilder
or another builder prop is very expensive and is used in several areas of a page, we wouldn’t necessarily want a future spawned for each of those scenarios. How can we prevent this from happening?
The Provider Pattern
A powerful pattern in Flutter, the Provider pattern allows you to gather data in an InheritedWidget
to be shared in multiple places. I will not go too in-depth into how to apply this pattern — there are articles and labs devoted to that already. I will mention the awesome Riverpod package that simplifies the process of sharing data throughout an application while also providing caching, data binding, and dependency injection.
Conclusion
Hopefully, this article has taught you some patterns to help better manage your Flutter applications. Regular widgets work well but separating logic from view will improve the cohesiveness, readability, and testability of your code. At times when reusing logic is important, use builder props.
Separation of concerns is vital to any type of software development, and it’s important to know that this is possible while staying in the context of a widget. When that logic is expensive and should execute once, use a provider.
You can find the code used in this article here.
Prior art
Simple React Patterns by Lucas Reis
Simple React Patterns Talk by Lucas Reis
Simple React by Kent C. Dodds
React Patterns by Kent C. Dodds
Sup?! I’m Ryan Edge. I am a Software Engineer at UpTech Studio and an inconsistent open source contributor. If you liked this article, be sure to follow and spread the love! Happy trails