Repository navigation
Introduce ListenableFutureBuilder to Simplify Using Controllers in the Composition of a StatelessWidget #121962
Description
Use case
I should be able to instantiate a Listenable controller with async work and use this controller to trigger rebuilds without using some external means to hold onto the Listenable variable. I also shouldn't have to subtype StatefulWidget to do this. I should be able to use straightforward composition to achieve this. I've created a widget (See PR #121963) that solves this problem, and there is a draft PR for it. But, before I jump to that, I will try to explain the existing problem.
Check out a live sample of the proposed solution here on Dartpad.
Existing Problem
On its face, this seems like something that would already be straightforward in Flutter, but it isn't. If you have a controller that extends ValueNotifier or ChangeNotifier, there is no straightforward way to compose a widget that listens to this without subtyping StatefulWidget, using a top-level singleton or something like Provider. Let's take a look at a simple example with AnimatedBuilder and ValueNotifier.
This example is obviously wrong because the parent widget could reinstantiate MyHomePage.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
class MyHomePage extends StatelessWidget {
MyHomePage({super.key});
//A StatelessWidget should never hold onto state in this way
ValueNotifier<int> _counter = ValueNotifier<int>(0);
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _counter,
builder: (context, child) => Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${_counter.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _counter.value++,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
),
);
}The above works well but it violates a fundamental principle of Flutter: StatelessWidgets should not hold onto state. This is because the parent widget could be reinstantiated, and the child widget would lose its state. This is why StatefulWidget exists. It allows the child widget to hold onto its state. So, we have a couple of choices:
- Turn this into a
StatefulWidgetand move the controller to theState - Declare the controller as a top-level singleton
- Use something like Provider to hold onto the variable and pass it down to the child widget
None of these options are elegant solutions to the problem, and the problem gets worse when we consider async work. They all add complexity to the codebase and make it harder to reason about.
Let's expand on the existing, already problematic example with async work.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
class MyHomePage extends StatelessWidget {
MyHomePage({super.key});
@override
Widget build(BuildContext context) => FutureBuilder<ValueNotifier<int>>(
//This is a problem because even though the FutureBuilder will store the result in
//State, if the parent rebuilds, this function will execute many times
future: getController(),
builder: (context, snapshot) => AnimatedBuilder(
animation: snapshot.data ?? ValueNotifier<int>(0),
builder: (context, child) => Scaffold(
appBar: AppBar(),
body: Center(
child: snapshot.connectionState == ConnectionState.done
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${snapshot.data!.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
)
: const CircularProgressIndicator.adaptive()),
floatingActionButton: FloatingActionButton(
onPressed: () => snapshot.data?.value++,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
),
),
);
}
Future<ValueNotifier<int>> getController() async =>
Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));Again, this example works, but it has a problem. If the parent widget reinstantiates this widget, the async function will execute again, causing the controller to be reinstantiated. Again, the controller resets. You can also see that this version is already getting more complicated and harder to read.
Take a look at the future property of the FutureBuilder. This is problematic because it's not a function. It forces the composition to reference a declaration or call a function to get the Future. Every time the parent widget rebuilds, the FutureBuilder will reference the declaration or call the function again. This recently caused the Flutter team to redo a YouTube video for clarification (#112676). In an ideal world, the FutureBuilder would hold onto the value from the Future, as it does, but only call a function lazily when it needs that value.
Proposal
The good news is that there is an easy way to solve several of these issues in one hit and massively simplify using controllers in the composition of a StatelessWidget.
Introducing ListenableFutureBuilder:
import 'package:flutter/material.dart';
import 'package:flutter_application_4/listenable_future_builder.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context) =>
ListenableFutureBuilder<ValueNotifier<int>>(
//This function is safe because it will only execute once
//ListenableFutureBuilder makes the first call, it will not execute the call again
listenable: getController,
builder: (context, child, snapshot) => Scaffold(
appBar: AppBar(),
body: Center(
child: snapshot.connectionState == ConnectionState.done
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${snapshot.data!.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
)
: const CircularProgressIndicator.adaptive()),
floatingActionButton: FloatingActionButton(
onPressed: () => snapshot.data?.value++,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
),
);
}
Future<ValueNotifier<int>> getController() async =>
Future.delayed(const Duration(seconds: 2), () => ValueNotifier<int>(0));This widget simplifies the code from the previous two examples, but more importantly, it solves the problem of the controller being reinstantiated when the parent widget is reinstantiated. It does this by calling listenable function only once and then listening to the Listenable returned by that function. ListenableFutureBuilder is a StatefulWidget and it holds onto the Listenable controller. This means that the controller will not be reinstantiated when the parent widget is reinstantiated. The result is a large simplification, but also removes dependence on things like Provider to ferry the controller in via an inherited widget.
Activity
MelbourneDeveloper commentedon Mar 5, 2023
Side Notes
It is important to look at the structure of
AnimatedWidget(AnimatedBuilder) andValueListenableBuilder. They have a similar caveat toFutureBuilder: they don't take a factory method to instantiate the controller. That means that the widget, instead of theState, holds the reference toListenable. If the parent widget rebuilds, there is a chance that the widget could get a reference to a differentListenable. That creates the potential for hard-to-trace bugs and creates the preconditions for controllers instantiating again and again.All this leads to the issue documented in the first code sample above. Even though
AnimatedBuilderis stateful, it doesn't hang onto theListenable.Listenablerelates to the observer pattern. So, this design is understandable, but allowing the State to hold a reference to the Listenable gives us distinct advantages.Of course, we don't want the
Stateobject to hang onto a reference of theListenableafter disposal, so we must remove the reference like this.Why Hang Onto the Listenable At All?
The
ListenableBuilderbase class accepts abuilderthrough the constructor. This is the type definition.This builder does not give us a great deal to work with when the rebuild occurs. We don't have access to the controller (Listenable) or even the state. We must store the controller in a variable somewhere and access it, which is what creates the dependency on singletons, or something like Provider.
ValueListenableBuildertakes a big step forward in this sense because it exposes the state (value) of the controller via thebuilder's type definition.We can access the value to see the state of the controller in this case. However, if you look back at the first code example above, you see that the code depends on holding a reference to the controller. If we want to call methods on the controller, we can't do so via the
buildercall.The
builderfunction solves this problem by exposing the controller (Listenable), so we can call methods on it and pass that to child widgets. It also allows us to know if theFuturecompleted or not.All that said, I'm not personally wedded to hanging onto the
Listenablein theState. It is possible to subscribe and forget. However, that means the consumer must manage the controller reference itself, which leads back to the issue in the first code example. The onus returns to the consumer to do something with the Listenable instance. This is where we leap from a simpleStatelessWidgetto aStatefulWidget. Ultimately, something needs to hold onto the controller, or there would be no way to call methods on the controller.ListenableFutureBuilderis aStatefulWidget, so it makes sense to allow it to hold a reference to the controller. If it doesn't do that, something else needs to.Merge branch 'master' into flutter#121962-ListenableFutureBuilder
Merge branch 'master' into flutter#121962-ListenableFutureBuilder
goderbauer commentedon Mar 27, 2023
Do you have an actual real-life use case where this widget would be beneficial? I am wondering who or what would provide the
getControllermethod. It seems odd to me that the listenable it creates is not owned by whoever implementsgetControllerand instead ownership implicitly passes on to theListenableFutureBuilder. That setup seems like it could easily lead to bugs if whatever implementsgetControlleris going away or the controller it returns need to change. That last case is especially concerning to me: There is no way to replace the controllerListenableFutureBuilderis using, so it may be operating with an invalid one leading to bugs and crashes.AathifMahir commentedon Mar 27, 2023
Hey,
Would you mind elaborating this with a better example, can't seem understand what your referring exactly
samuelkchris commentedon Mar 28, 2023
Here are a few things you should be aware of that can cause problems:
The getController function returns a new ValueNotifier instance every time it is called, even if the value of the ValueNotifier is the same. This can cause unnecessary widget rebuilds and performance issues.
The ValueNotifier in this example is mutable, and there are no guarantees that the widget tree will be rebuilt when its value changes. This can lead to bugs and unexpected behavior in the long run, especially as the widget tree becomes more complex.
If the getController function fails to return a ValueNotifier instance, the ListenableFutureBuilder will fail to build and throw an error.
Merge branch 'master' into flutter#121962-ListenableFutureBuilder
Merge remote-tracking branch 'flutter/flutter#121962-ListenableFuture…
MelbourneDeveloper commentedon Mar 28, 2023
Hi @goderbauer. Let me try to address these points the best I can.
This widget doesn't so much enable a novel use case as it does simplify an existing one. The main example is when you need a controller for your widget, and initialization requires async work. That controller will probably be transient. I.e., not a singleton. If it's a singleton, this widget is most likely not necessary. This widget will most likely sit at the base of the widget tree. The use case will most likely be something like navigating to a route.
The easiest way I can demonstrate this is with the simplest example. Given the above scenario, you would need some dependency manager to hold onto the controller for you, or you would have to create a
StatefulWidgetto hold the state. You are right. In this case,ListenableFutureBuilderpicks up ownership of the controller, and there's a reason for that.Correct me if I'm wrong, but the above scenario would require this code (unless you manage the controller elsewhere in the tree). You could also do this without a
FutureBuilderand move more logic into the state.Now take a look at the simplified version with
ListenableFutureBuilder. The main difference is that you don't need to create a new type at all. You can compose your entire widget tree, including the controller without extending anything. And, as a sweetener, the logic of theAnimatedBuilderandFutureBuilderget rolled into one.The scenario I envision is that the
getController()method is a transient async factory. This function probably wouldn't spit up a singleton because you probably wouldn't need this widget for that. Many apps mint controllers again and again so as to refresh the state.Whatever implements
getControllerwill most likely not hang onto anything related to the controller. So, it can safely go away without affecting anything here.I would like to address this one, but I'm not sure I understand it. If we look at the similar widget
AnimatedBuilder(AnimatedWidget) under the hood, we see that theListenableisfinal. We don't replace the controller onAnimatedBuilder.It is true that the factory method could throw an exception. That's why the widget handles async errors in the same way that FutureBuilder does. If an error occurs, the widget can inspect the snapshot and adjust the UI accordingly.
It is also true that any controller can end up in an invalid state. In those cases, the normal approach is to adjust the UI accordingly, and the hope would be that the fault is transient and gets rectified by navigating back to the same page.
Could you please give me some examples that may lead to bugs and crashes so I can work through them?
Christian
samuelkchris commentedon Mar 28, 2023
Hello Christian, here is what i have noticed, correct me if am wrong.
Incorrect data type:
Let's say you modify the getController function to return a different type of Listenable, like a ChangeNotifier, like this:
you'll get a type error because ListenableFutureBuilder is expecting a ValueNotifier, but it's getting a ChangeNotifier instead. This could cause your application to crash or behave unexpectedly and this is one example.
Thank you for the great work,
Samuel
MelbourneDeveloper commentedon Mar 28, 2023
@goderbauer the issue fundamentally addresses verbosity. In the same sense that records can allow us to reduce the number of types we need to declare, this may allow us to declare fewer widget/state pairs. Although, I'd be happy to be corrected on that.
I wrote the PR because it was the best way to demonstrate the issue. Perhaps there are other ways to address this issue, but it was a good exercise in exemplifying the problem and I think it is a useful widget.
MelbourneDeveloper commentedon Mar 28, 2023
@samuelkchris
You'll get a compile time error because Dart has type safety.
samuelkchris commentedon Mar 28, 2023
Doesn't this limit flexibility as custom implementation may always be required?
MelbourneDeveloper commentedon Mar 30, 2023
Hi @goderbauer
Do you have any thoughts or comments about this reply?
#121962 (comment)
goderbauer commentedon Mar 31, 2023
What is a use case for this? What entity would vend controllers like that? Usually, the entity creating the controller also wants to own it. So, in order to evaluate if this proposal makes sense we need to know what it is supposed to be used for. In other words: what is the problem you are trying to solve with either the ListenableFutureBuilder or with the more verbose StatefulWidget approach you gave. Sure, the ListenableFutureBuilder reduces the verbosity of the StatefulWidget approach, but again, it is not clear to me what problem the StatefulWidget approach is supposed to address in the first place.
The listenable property on the AnimatedBuilder is final, yes, because widgets in flutter are immutable and thus must only have final fields. However, if you look at the long-living state object backing the AnimatedBuilder you will see that it very much responds if the widget its associated with is replaced with one that has a different listenable:
flutter/packages/flutter/lib/src/widgets/transitions.dart
Lines 114 to 120 in 9d820aa
It will stop listing to the old listenable and instead listen to the new one.
MelbourneDeveloper commentedon Apr 11, 2023
@goderbauer
Yes, you are right, and I see your point. There are probably many use cases where an
AnimatedBuildermust change the listener. I haven't encountered those, but it's probably common with animations. In the case I am describing, the controller drives the current screen, so the controller will not change.In my personal case, a service locator vends the controllers. My service locator is ioc_container, but get_it is another common one.
In this case, the entity doesn't want to own it. The entity (service locator) only mints transient controllers. It doesn't hold onto them and is not interested in them. In this case, we create a widget explicitly to hold onto the controller, which in turn calls
setState()when notifications occur.But, this approach is not limited to service locators. You can mint the controllers from global factories, or libraries like Riverpod.
The use case is a scenario where the controller drives the logic for some given screen. Instead of putting the state and logic in the stateful widget's
State<>, we move them to aChangeNotifierorValueNotifier<>. It's a popular approach, andBloc/Cubitwork similarly. There is also StateNotifier, but none of these implementListenableAFAIK. This use case is not related to animations. The main driver for doing this is separating the logic and UI concerns. There are severalBuilderwidgets in the Flutter framework already. They listen to theListenable,and they callsetState()upon notification.This is a matter of choice. You don't have to move logic out of the
Statepart of aStatefulWidgetbut many teams find that this makes the code easier to reason about because of the separation of concerns.It feels like I'm missing something here. As I mentioned, moving the logic of a widget into a controller such as a
ChangeNotifieror aValueNotifieris a pretty common approach that many in the community take. Everything I have mentioned here relates back to this document titled Simple app state management. Note that this solution depends onProvider, which is part of the reason why I documented this issue. I don't think this approach should depend onProvider.You seem to suggest a totally different way to do this. Do you mean that using something like a
Listenableas a controller is a bad or wonky approach in the first place? Are you saying it's better to stick to vanillaStatefulWidget/Statepairs? If not, what is the better way to do what I've documented here?I don't necessarily disagree. I quite often write widgets that way. This issue comes from the assumption that many people want to use
Listenableto handle logic.MelbourneDeveloper commentedon Apr 11, 2023
I have released listenable_future_builder as a package on pub dev:
listenable_future_builder
The documentation should explain why someone might want to use it, and there are several samples in the documentation.