Make Your build() Clean with StateHolder

I am a freelance mobile app developer, mainly working on Flutter. I'm also developing a Flutter package named "crop_your_image", which enables Flutter app developers to build cropping images functionality in their app with their own designed UI. https://pub.dev/packages/crop_your_image
If we take a closer look at build() method, we sometimes find it "messy", messy with calculation logics to create values even though we may want to focus on the structure of the widget tree and sometimes on the dependency graph.
Even though we may want to focus on what widgets are built and how the structure of the widget tree looks like, the messy lines before return makes it difficult.
class MyMessyArticlesPage extends StatefulWidget {
const MyMessyArticlesPage();
// value given as constructor argument
final Category category;
@override
State<MyMessyArticlesPage> createState() => _MyMessyArticlesPageState();
}
class _MyMessyArticlesPageState extends State<MyMessyArticlesPage> {
// local state
bool _showMineOnly = false;
@override
Widget build(BuildContext context) {
// app state
final articles = Articles.of(context);
// another app state
final me = MyAccount.of(context);
// filter articles by given category and local state
final visibleArticles = articles.where((article) {
return article.category == category;
}).where((article) {
return article.author.id = me.id;
});
// ...so, where is return statement and widget tree?
// define another local variable
final hasData = visibleArticles.isNotEmpty();
// finally we've got to return statement
// but what variables are used in the end?
return ListView.builder(
itemCount: visibleArticles.length;
itemBuilder: (context, index) {
return SomeArticleItem(visibleArticles[index]);
}
)
}
}
Even though I've made the example above as simple as possible, it's already messy, don't you think so?
Why does our widget look so messy? What causes this problem, and how we can address it?
In this article, I'll introduce one handy idea, named StateHolder, to refactor these messy lines.
StateHolder overview
With StateHolder, the code above will be refactored as follows:
class MyArticlesPage extends StatefulWidget {
const MyArticlesPage();
final Category category;
@override
State<MyArticlesPage> createState() => _MyMessyArticlesPageState();
}
class _MyArticlesPageState extends State<MyArticlesPage> {
bool _showMineOnly = false;
@override
Widget build(BuildContext context) {
// put all the values to StateHolder
final stateHolder = MyArticlesStateHolder(
articles: Articles.of(context),
me: MyAccount.of(context),
category: widget.category,
showMineOnly: _showMineOnly,
);
// and, that's it! we can proceed to building widgets
return ListView.builder(
// stateHolder will expose required values
itemCount: stateHolder.visibleArticles.length;
itemBuilder: (context, index) {
return SomeArticleItem(stateHolder.visibleArticles[index]);
}
)
}
}
What build() method does now is only creating StateHolder and building widgets using it, and the method no longer looks messy, doesn't it?
Now, let's dive into how we implement StateHolder and how this idea helps us, explaining what causes the original issue in detail.
What makes build() method messy?
First of all, build() method uses values given in various manners, such like below:
constructor arguments
app state
local state
computed values from the values above
Not all the widgets require all the values. Some widgets, especially a lot of extracted StatelessWidget will only need constructor arguments, and some other widgets may only depend on app state.
However, some widgets require all(or almost all) of them, where the last "computed value" is often problematic because it requires some calculation inside build() method.
@override
Widget build(BuildContext context) {
// here, we want to focus on widgets
// but we need some logic to calculate categorizedArticles
final articles = Articles.of(context);
final categorizedArticles = articles.where((article) {
return article.category == category;
});
// ...some more logics to create values
}
What is worse is that the logic tends to be difficult to be tested, because we have to use “widget test”, not “unit test”, meaning we have to test the logic together with the widgets’ behavior.
It is true that this problem can be solved by using some state management packages. You can make some Riverpod's provider that does all the things above, for instance. However, this solution strongly depends on the packages’ functionalities, so I'll introduce the idea of StateHolder which is package agnostic and can collaborate with many state management packages.
Rules of StateHolder
So how can we make StateHolder?
We have some rules below to keep StateHolder handy and testable.
receives all the values related to “state” via StateHolder’s constructor arguments
is immutable
exposes computed values with
late finalfieldsis independent from state management packages or the Flutter framework
Based on these rules, MyArticlesStateHolder will be implemented as follows:
class MyArticlesStateHolder {
/// receive all the values via constructor arguments
const MyArticlesStateHolder({
required this.articles,
required this.me,
required this.category,
required this.showMineOnly,
});
/// everything is `final`
final List<Article> articles;
final Account me;
final Category category;
final bool showMineOnly;
/// expose computed values with `late final`
/// widgets can use this value with `stateHolder.visibleArticles`
/// this calculation only happens once even if widget uses it multiple times
late final visibleArticles = articles.where((article) {
return article.category == category;
}).where((article) {
return article.author.id = me.id;
});
}
As long as following this manner, StateHolder looks so simple that we can focus on values and calculations.
This also enables us to test the calculation logic with simple unit tests like below.
void main() {
late MyArticlesStateHolder stateHolder;
setUp(() {
// prepare for some test data
stateHolder = MyArticlesStateHolder(
articles: [article1, article2, article3],
me: Account(id: '123', name: 'chooyan'),
category: Category.tech,
showMineOnly: true,
);
});
test('visibleArticles should contain article2 and article3', () {
expect(stateHolder.visibleArticles.length, 2);
expect(stateHolder.visibleArticles.contains(article2), isTrue);
expect(stateHolder.visibleArticles.contains(article3), isTrue);
});
}
Some more points about StateHolder
The lifecycle of StateHolder is very short. It is created at the beginning of build() method and only used within it, and that's it. In other words, it will be disposed of immediately after build() method is finished.
Also, because build() method is not supposed to have side effects, StateHolder is also never mutated. We can simply define all the fields as final. We also don’t need to make copyWith kind of methods because we never cache the created object and each rebuild will create brand new ones.
Computed values, like visibleArticles in the example above, are also final and will be calculated when it is accessed, as it is defined with late.
One more important point is that StateHolder shouldn't receive any objects provided by state management packages or the Flutter framework, such as BuildContext, WidgetRef, etc. This will break the testability explained above.
This rule also keeps the widget’s dependencies clearly visible.
Widgets usually depend on some other widgets, especially InheritedWidgets or some state management packages' components.
Those dependencies should be clear to understand what value will affect the behavior of the widget and when it will be rebuilt.
When to use StateHolder?
So when do we use this StateHolder? Should we introduce this object to all the widgets?
The answer is “No”.
The advantage of this idea is "lightweight". It can’t be a part of application architecture but only a handy idea to refactor build() method, regardless of how the entire app is structured.
If your build() method is already clean and simple, just go ahead. If you find any build() method consisting of a lot of values and calculations before building widgets, try this idea.
Wrap up
So far, we've seen how build() method can be messy and how the idea of StateHolder can help us to refactor it.
StateHolder will help us separate the calculation logic from the widget construction code, and enable us to focus on the structure of the widget tree, as well as its dependencies.
Before finishing this article, I have to add one more thing that this idea doesn't cover how to refactor build() methods with local methods declaration or function calls, such as useEffect(), ref.listen(), or addPostFrameCallback().
class MyMessyArticlesPage extends HookConsumerWidget {
const MyMessyArticlesPage();
final Category category;
@override
Widget build(BuildContext context, WidgetRef ref) {
useEffect(() {
// some long initialization logic here.
return null;
}, const []);
ref.listen(someProvider, (prev, next) {
// some long and complex logic here.
});
Future<void> submit() async {
final result = await ref.read(someProvider.notifier).submit();
// some more logic here.
}
return SomeWidget();
}
}
This idea DOESN'T solve ALL the concerns we encounter when writing build() methods, but still it will help you keep your code clean and testable in a lot of cases.



