-
Notifications
You must be signed in to change notification settings - Fork 28.9k
Description
Related to the discussion around hooks #25280
TL;DR: It is difficult to reuse State
logic. We either end up with a complex and deeply nested build
method or have to copy-paste the logic across multiple widgets.
It is neither possible to reuse such logic through mixins nor functions.
Problem
Reusing a State
logic across multiple StatefulWidget
is very difficult, as soon as that logic relies on multiple life-cycles.
A typical example would be the logic of creating a TextEditingController
(but also AnimationController
, implicit animations, and many more). That logic consists of multiple steps:
-
defining a variable on
State
.TextEditingController controller;
-
creating the controller (usually inside initState), with potentially a default value:
@override void initState() { super.initState(); controller = TextEditingController(text: 'Hello world'); }
-
disposed the controller when the
State
is disposed:@override void dispose() { controller.dispose(); super.dispose(); }
-
doing whatever we want with that variable inside
build
. -
(optional) expose that property on
debugFillProperties
:void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('controller', controller)); }
This, in itself, is not complex. The problem starts when we want to scale that approach.
A typical Flutter app may have dozens of text-fields, which means this logic is duplicated multiple times.
Copy-pasting this logic everywhere "works", but creates a weakness in our code:
- it can be easy to forget to rewrite one of the steps (like forgetting to call
dispose
) - it adds a lot of noise in the code
The Mixin issue
The first attempt at factorizing this logic would be to use a mixin:
mixin TextEditingControllerMixin<T extends StatefulWidget> on State<T> {
TextEditingController get textEditingController => _textEditingController;
TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
}
@override
void dispose() {
_textEditingController.dispose();
super.dispose();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty('textEditingController', textEditingController));
}
}
Then used this way:
class Example extends StatefulWidget {
@override
_ExampleState createState() => _ExampleState();
}
class _ExampleState extends State<Example>
with TextEditingControllerMixin<Example> {
@override
Widget build(BuildContext context) {
return TextField(
controller: textEditingController,
);
}
}
But this has different flaws:
-
A mixin can be used only once per class. If our
StatefulWidget
needs multipleTextEditingController
, then we cannot use the mixin approach anymore. -
The "state" declared by the mixin may conflict with another mixin or the
State
itself.
More specifically, if two mixins declare a member using the same name, there will be a conflict.
Worst-case scenario, if the conflicting members have the same type, this will silently fail.
This makes mixins both un-ideal and too dangerous to be a true solution.
Using the "builder" pattern
Another solution may be to use the same pattern as StreamBuilder
& co.
We can make a TextEditingControllerBuilder
widget, which manages that controller. Then our build
method can use it freely.
Such a widget would be usually implemented this way:
class TextEditingControllerBuilder extends StatefulWidget {
const TextEditingControllerBuilder({Key key, this.builder}) : super(key: key);
final Widget Function(BuildContext, TextEditingController) builder;
@override
_TextEditingControllerBuilderState createState() =>
_TextEditingControllerBuilderState();
}
class _TextEditingControllerBuilderState
extends State<TextEditingControllerBuilder> {
TextEditingController textEditingController;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(
DiagnosticsProperty('textEditingController', textEditingController));
}
@override
void dispose() {
textEditingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(context, textEditingController);
}
}
Then used as such:
class Example extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TextEditingControllerBuilder(
builder: (context, controller) {
return TextField(
controller: controller,
);
},
);
}
}
This solves the issues encountered with mixins. But it creates other issues.
-
The usage is very verbose. That's effectively 4 lines of code + two levels of indentation for a single variable declaration.
This is even worse if we want to use it multiple times. While we can create aTextEditingControllerBuilder
inside another once, this drastically decrease the code readability:@override Widget build(BuildContext context) { return TextEditingControllerBuilder( builder: (context, controller1) { return TextEditingControllerBuilder( builder: (context, controller2) { return Column( children: <Widget>[ TextField(controller: controller1), TextField(controller: controller2), ], ); }, ); }, ); }
That's a very indented code just to declare two variables.
-
This adds some overhead as we have an extra
State
andElement
instance. -
It is difficult to use the
TextEditingController
outside ofbuild
.
If we want aState
life-cycles to perform some operation on those controllers, then we will need aGlobalKey
to access them. For example:class Example extends StatefulWidget { @override _ExampleState createState() => _ExampleState(); } class _ExampleState extends State<Example> { final textEditingControllerKey = GlobalKey<_TextEditingControllerBuilderState>(); @override void didUpdateWidget(Example oldWidget) { super.didUpdateWidget(oldWidget); if (something) { textEditingControllerKey.currentState.textEditingController.clear(); } } @override Widget build(BuildContext context) { return TextEditingControllerBuilder( key: textEditingControllerKey, builder: (context, controller) { return TextField(controller: controller); }, ); } }
Activity
rrousselGit commentedon Mar 2, 2020
cc @dnfield @Hixie
As requested, that's the full details on what are the problems solved by hooks.
dnfield commentedon Mar 2, 2020
I'm concerned that any attempt to make this easier within the framework will actually hide complexity that users should be thinking about.
It seems like some of this could be made better for library authors if we strongly typed classes that need to be disposed with some kind of
abstract class Disposable
. In such a case you should be able to more easily write a simpler class like this if you were so inclined:Which gets rid of a few repeated lines of code. You could write a similar abstract class for debug properties, and even one that combines both. Your init state could end up looking something like:
Are we just missing providing such typing information for disposable classes?
rrousselGit commentedon Mar 2, 2020
Widgets hides the complexity that users have to think about.
I'm not sure that's really a problem.
In the end it is up to users to factorize it however they want.
The problem is not just about disposables.
This forgets the update part of the problem. The stage logic could also rely on lifecycles like didChangeDependencies and didUpdateWidget.
Some concrete examples:
didChangeDependencies
.super.build(context)
rrousselGit commentedon Mar 2, 2020
There are many examples in the framework where we want to reuse state logic:
...
These are nothing but a way to reuse state with an update mechanism.
But they suffer from the same issue as those mentioned on the "builder" part.
That causes many problems.
For example one of the most common issue on Stackoverflow is people trying to use
StreamBuilder
for side effects, like "push a route on change".And ultimately their only solution is to "eject" StreamBuilder.
This involves:
That's a lot of work, and it's effectively not reusable.
Hixie commentedon Jul 28, 2020
I really have trouble understanding why this is a problem. I've written plenty of Flutter applications but it really doesn't seem like that much of an issue? Even in the worst case, it's four lines to declare a property, initialize it, dispose it, and report it to the debug data (and really it's usually fewer, because you can usually declare it on the same line you initialize it, apps generally don't need to worry about adding state to the debug properties, and many of these objects don't have state that needs disposing).
I agree that a mixin per property type doesn't work. I agree the builder pattern is no good (it literally uses the same number of lines as the worst case scenario described above).
Hixie commentedon Jul 28, 2020
With NNBD (specifically with
late final
so that initiializers can referencethis
) we'll be able to do something like this:You'd use it like this:
Doesn't seem to really make things better. It's still four lines.
Hixie commentedon Jul 28, 2020
What do they hide?
rrousselGit commentedon Jul 28, 2020
The problem is not the number of lines, but what these lines are.
StreamBuilder
may be about as many lines asstream.listen
+setState
+subscription.close
.But writing a
StreamBuilder
can be done without any reflection involved, so to say.There is no mistake possible in the process. It's just "pass the stream, and build widgets out of it".
Whereas writing the code manually involves a lot more thoughts:
StreamBuilder
, there's no need to write unit tests for listening to the stream, that would be redundant. But if we write it manually all the time, it's entirely feasible to make a mistakerrousselGit commentedon Jul 28, 2020
The logic of switching between two
Future
is fairly complex too, considering it doesn't have asubscription.close()
.They don't, because they do not want to deal with the complexity of maintaining the debugFillProperties method.
But if we told developers "Would you like it is out of the box all of your parameters and state properties were available on Flutter's devtool?" I'm sure they would say yes
Many people have expressed to me their desire for a true equivalent to React's devtool. Flutter's devtool is not yet there.
In React, we can see all the state of a widget + its parameters, and edit it, without doing anything.
Similarly, people were quite surprised when I told them that when using
provider
+ some other packages of mine, by default their entire application state is visible to them, without having to do anything (modulo this annoying devtool bug)549 remaining items
rrousselGit commentedon Mar 29, 2024
There's not really a difference between useState from React and what Swelte/Vue offer in that regard IMO.
The difference is IMO mainly about the component system having completely different lifecycles
Vue/Swelte/Angular don't really have a "render"/"build" method. They instead have a single "initState"-like method, and rely on observable objects.
If we want to compare React to other modern approaches, I'd look at Jetpack Compose.
It too has a "build" method. It faces problems much closer to the ones Flutter face, and offers unique solutions.
Compose has various interesting solutions to "useState" and "StreamBuilder" using custom language features.
async
,async*
andsync*
) dart-lang/language#4102benthillerkus commentedon Feb 2, 2025
Soooo, what's the current plan?
lucavenir commentedon Apr 30, 2025
It's been a while since this issue was open, and three months after macros' death.
I've read on the 2025 roadmap that there'll be a focus on widgets' verbosity.
It's a surprising take with regard to this issue. Is this issue really just about verbosity, or is this about isolating and reusing ephemeral state from the UI code, like some others do?
Is there anything you can share with us atm?
esDotDev commentedon Apr 30, 2025
It's definitely not just about verbosity, it's about an inability to keep your widgets DRY and robust.
Specifically you have to duplicate a lot of boilerplate code over and over, when it comes to dependency-binding, intialization and disposal. The problem isn't that this code is too verbose, it's that we can not define it in one place, we have to type it over and over.
Robustness is hurt as a lot of this code is optional, but your app will just have hard to spot bugs if you don't implement it. Specifically disposing of things or properly injecting dependencies when they change.
bernaferrari commentedon Apr 30, 2025
AFAIK (and I really don't know anything) the 'verbosity plan' is mostly decorator (
widget.padding()
) and making widgets less painful, but it is not touching stateful widgets or logic (which, again, I could be wrong)escamoteur commentedon Apr 30, 2025
This is already an absolut monster thread and I honestly have lot track what we are discussion about. Maybe we should start a new one from the current state of the Flutter framework and add the core issues from here?
benthillerkus commentedon May 1, 2025
back 5 years ago when this thread was started it was already the third thread or so.
I don't think there's really anything new to say besides that Hooks have proven themselves in React and flutter_hooks?
escamoteur commentedon May 1, 2025
@benthillerkus if it's just that we want to integrate hooks or hook like behavior that we should probably make a new issue for that.
I totally agree that the it is an amazing concept although flawed because of its not directly supported by the framework therefore needs the hack of counting
useXCalls
my package watch_it uses the same principle although with a more opinionated API.I filed a request on the dart repo dart-lang/language#1427 which would make implementing hook like packages way easier without the need of changing the framework.
My point was the the scope of this issue is too broad to say what actions should derive now from it
benthillerkus commentedon May 1, 2025
it doesn't really make sense to argue further if the position of the framework team is still "everything is fine, actually"
escamoteur commentedon May 1, 2025
@benthillerkus then at least voting or leaving a comment on my linked issue would help because with that we could remove the hooks limitation that you can't have conditionals and that hooks have to be called always in the same order
benthillerkus commentedon May 1, 2025
@escamoteur honestly it's just not a problem in practice.
lucavenir commentedon May 1, 2025
I kindly disagree.
I also strongly disagree on closing this issue.
AFAIK React regretted some parts of the hooks API, particularly
useEffect
. AFAIK some folks are questioning the framework itself lately, because of the reactive model itself and because of the last changes in hope of fixing these APIs.Flutter wouldn't have the latter problem, but my personal DX with
flutter_hooks
is still "bad". It's hardly worth it to learn these APIs. It's still quite easy to mess it up.Skill issues? Sure, but
useEffect
still feels like a foot gun, even in Flutter. The only viable hook I end up writing is a "side effect hook", to get rid ofFutureBuilder
or similar APIs. But that's about it.The problem (first comment) is still here. Literally every mature framework/ecosystem sorted this problem out. Flutter didn't.
A side note, Flutter distinguishes clearly between ephemeral and app state (see docs). App state is usually handled by battle tested state managers (folks hardly use custom inherited widgets - they use providers or cubits, and the docs suggest them as well). But what about ephemeral state?
I commented here just to understand what's the roadmap for this issue specifically, hearing from the team itself.
escamoteur commentedon May 1, 2025
@lucavenir did you check out watch_its API ? I think it offers most of hooks simplicity but has a way more intuitive API