New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Reusing state logic is either too verbose or too difficult #51752
Comments
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 class AutomaticDisposingState<T> extends State<T> {
List<Disposable> _disposables;
void addDisposable(Disposable disposable) {
assert(!_disposables.contains(disposable));
_disposables.add(disposable);
}
@override
void dispose() {
for (final Disposable disposable in _disposables)
disposable.dispose();
super.dispose();
}
} 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: @override
void initState() {
super.initState();
controller = TextEditingController(text: 'Hello world');
addDisposable(controller);
addProperty('controller', controller);
} Are we just missing providing such typing information for disposable classes? |
Widgets hides the complexity that users have to think about. 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:
|
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. And ultimately their only solution is to "eject" StreamBuilder.
That's a lot of work, and it's effectively not reusable. |
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). |
With NNBD (specifically with typedef Initializer<T> = T Function();
typedef Disposer<T> = void Function(T value);
mixin StateHelper<T extends StatefulWidget> on State<T> {
bool _active = false;
List<Property<Object>> _properties = <Property<Object>>[];
@protected
void registerProperty<T>(Property<T> property) {
assert(T != Object);
assert(T != dynamic);
assert(!_properties.contains(property));
_properties.add(property);
if (_active)
property._initState();
}
@override
void initState() {
_active = true;
super.initState();
for (Property<Object> property in _properties)
property._initState();
}
@override
void dispose() {
for (Property<Object> property in _properties)
property._dispose();
super.dispose();
_active = false;
}
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
for (Property<Object> property in _properties)
property._debugFillProperties(properties);
}
}
class Property<T> {
Property(this.owner, this.initializer, this.disposer, [ this.debugName ]) {
owner.registerProperty(this);
}
final StateHelper<StatefulWidget> owner;
final Initializer<T> initializer;
final Disposer<T> disposer;
final String debugName;
T value;
void _initState() {
if (initializer != null)
value = initializer();
}
void _dispose() {
if (disposer != null)
disposer(value);
value = null;
}
void _debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties.add(DiagnosticsProperty(debugName ?? '$T property', value));
}
} You'd use it like this: class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with StateHelper<MyHomePage> {
late final Property<int> _counter = Property<int>(this, null, null);
late final Property<TextEditingController> _text = Property<TextEditingController>(this,
() => TextEditingController(text: 'button'),
(TextEditingController value) => value.dispose(),
);
void _incrementCounter() {
setState(() {
_counter.value += 1;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the ${_text.value.text} this many times:',
),
Text(
'${_counter.value}',
style: Theme.of(context).textTheme.headline4,
),
TextField(
controller: _text.value,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
} Doesn't seem to really make things better. It's still four lines. |
What do they hide? |
The problem is not the number of lines, but what these lines are.
Whereas writing the code manually involves a lot more thoughts:
|
They don't, because they do not want to deal with the complexity of maintaining the debugFillProperties method. Many people have expressed to me their desire for a true equivalent to React's devtool. Flutter's devtool is not yet there. Similarly, people were quite surprised when I told them that when using |
I have to admit that I'm not a big fan of FutureBuilder, it causes a lot of bugs because people don't think about when to trigger the Future. I think it would not be unreasonable for us to drop support for it. StreamBuilder is ok I guess but then I think Streams themselves are too complicated (as you mention in your comment above) so... Why does someone have to think about the complexity of creating Tweens? ListView doesn't really hide the logic of mounting a widget as it appears; it's a big part of the API.
I really don't understand the concern here. The lines seem pretty much like simple boilerplate. Declare the thing, initialize the thing, dispose of the thing. If it's not the number of lines, then what's the problem? |
I'll agree with you that FutureBuilder is problematic. It's a bit off-topic, but I would suggest that in development, Flutter should trigger a fake hot-reload every few seconds. This would highlight misuses of FutureBuilder, keys, and many more.
We agree on that. My point was that we cannot criticize something like hooks with "it hides logic", as what hooks do is strictly equivalent to what a In the end, I think animations are a good comparison. Animations have this concept of implicit vs explicit. When we translate this concept to listening to streams, More specifically, with
Builders are powerful to simplify the application. For example, Widget build(context) {
final AsyncSnapshot<T> snapshot = useStream(stream);
} In the continuation, double opacity;
Widget build(context) {
final double animatedOpacity = useAnimatedDouble(opacity, duration: Duration(milliseconds: 200));
return Opacity(
opacity: animatedOpacity,
child: ...,
);
} |
That's not the argument. The argument is "it hides logic that developers should be thinking about". |
Do you have an example of such logic that the developers should be thinking about? |
Like, who owns the TextEditingController (who creates it, who disposes of it). |
Like with this code? Widget build(context) {
final controller = useTextEditingController();
final focusNode = useFocusNode();
} The hook creates it and disposes of it. I'm not sure what is unclear about this. |
Yes, exactly. I have no idea what the lifecycle of the controller is with that code. Does it last until the end of the lexical scope? The lifetime of the State? Something else? Who owns it? If I pass it to someone else, can they take ownership? None of this is obvious in the code itself. |
It looks like your argument is caused more by a lack of understanding on what hooks do rather than a real issue.
Nor do you have to think about it. It is no longer the responsibility of the developer.
The lifetime of the State
The hook owns the controller. It is part of the API of In a way, these questions apply to
|
In general, you can think of: final value = useX(argument); as a strict equivalent to: XBuilder(
argument: argument,
builder: (context, value) {
},
); They have the same rules and the same behavior. |
I think fundamentally that's the disagreement here. Having a function-like API that returns a value that has a defined life-time that isn't clear is, IMHO, fundamentally very different than an API based on passing that value to a closure. I have no problem with someone creating a package that uses this style, but it's a style contrary to the kind that I would want to include in the core flutter API. |
@Hixie They don't solve the same problems though. Maybe I'm wrong here but last fall when trying out flutter I believe that if I would have needed three of those builders in one widget it would have been a lot of nesting. Compared to three hooks (three lines). Such stuff like sharing state logic easily between widgets was a thing I was missing when trying out flutter fall of 2019. There could of course be a lot of other possible solutions. Maybe it's already been solved and I just didn't find it in the docs. |
I'm definitely not suggesting using the builder approach, as the OP mentions, that has all kinds of problems. What I would suggest is just using initState/dispose. I don't really understand why that's a problem. I'm curious how people feel about the code in #51752 (comment). I don't think it's any better than initState/dispose, but if people like hooks, do they like that too? Is hooks better? Worse? |
@Hixie Hooks are nice to use because they compartmentalize the life cycle into a single function call. If I use a hook, say
I think what you're asking is the equivalent of asking why have functions when we can manually take care of effects every time. I agree it is not exactly the same, but it broadly feels similar. It seems that you have not used hooks before so the problems don't seem too apparent to you, so I would encourage you to do a small or medium size project using hooks, with the |
I'll add a few thoughts from the React perspective. Hooks are definitely "hiding" things. Or, depending on how you look at it, encapsulate them. In particular, they encapsulate local state and effects (I think our "effects" are the same things as "disposables"). The "implicitness" is in that they automatically attach the lifetime to the Component inside of which they're called. This implicitness is not inherent in the model. You could imagine an argument being explicitly threaded through all calls — from the Component itself throughout custom Hooks, all the way to each primitive Hook. But in practice, we found that to be noisy and not actually useful. So we made currently executing Component implicit global state. This is similar to how Okay, so it's functions with implicit hidden state inside them, that seems bad? But in React, so are Components in general. That's the whole point of Components. They're functions that have a lifetime associated with them (which corresponds to a position in the UI tree). The reason Components themselves are not a footgun with regards to state is that you don't just call them from random code. You call them from other Components. So their lifetime makes sense because you remain in the context of UI code. However, not all problems are component-shaped. Components combine two abilities: state+effects, and a lifetime tied to tree position. But we've found that the first ability is useful on its own. Just like functions are useful in general because they let you encapsulate code, we were lacking a primitive that would let us encapsulate (and reuse) state+effects bundles without necessarily creating a new node in the tree. That's what Hooks are. Components = Hooks + returned UI. As I mentioned, an arbitrary function hiding contextual state is scary. This is why we enforce a convention via a linter. Hooks have "color" — if you use a Hook, your function is also a Hook. And the linter enforces that only Components or other Hooks may use Hooks. This removes the problem of arbitrary functions hiding contextual UI state because now they're no more implicit than Components themselves. Conceptually, we don't view Hook calls as plain function calls. Like In practical terms, there are a few things here. First, it's worth noting Hooks aren't an "extra" API to React. They're the React API for writing Components at this point. I think I'd agree that as an extra feature they wouldn't be very compelling. So I don't know if they really make sense for Flutter which has an arguably different overall paradigm. As for what they allow, I think the key feature is the ability to encapsulate state+effectful logic, and then chain it together like you would with regular function composition. Because the primitives are designed to compose, you can take some Hook output like I want to emphasize this is not about reducing the boilerplate but about the ability to dynamically compose pipelines of stateful encapsulated logic. Note that it is fully reactive — i.e. it doesn't run once, but it reacts to all changes in properties over time. One way to think of them is they're like plugins in an audio signal pipeline. While I totally get the wary-ness about "functions that have memories" in practice we haven't found that to be a problem because they're completely isolated. In fact, that isolation is their primary feature. It would fall apart otherwise. So any codependence has to be expressed explicitly by returning and passing values into the next thing in the chain. And the fact that any custom Hook can add or remove state or effects without breaking (or even affecting) its consumers is another important feature from the third-party library perspective. I don't know if this was helpful at all, but hope it sheds some perspective on the programming model. |
The Such For example, with Similarly, hooks have an equivalent to Widget's An example of that is With String messageId;
Widget build(context) {
final Future<Message> message = useMemo(() => fetchMessage(messageId), [messageId]);
} In this situation, even if the build method is called again 10 times, as long as It's worth noting that I do not think I didn't remember where, but I remember suggesting that hooks in the ideal world would be a custom function generator, next to |
What I like is the optionality here, and the way it serves the common use case nicely. In most cases, you don't need to sync, so you could just have // renamed Component to Prop, just for fun
late final _anim = addProp(AnimationProp(duration: 1.seconds, vsync: this)); And this will take care of setup/teardown/rebuilds (the 90% use cases, ymmv). But then if you do need hooks, you can wire them in: late final _anim = addProp(AnimationProp(duration: widget.duration, vsync: widget.vsync), (prop){
prop.onWidgetUpdate((w) => w.duration, prop.instance.duration); // register duration sync
prop.onWidgetUpdate((w) => w.vsync, prop.instance.vsync); // register vsync sync
// etc
}); I guess It's not as bullet-proof as hooks in terms of always rebuilding when a dep changes, you still do have to manually add one line, for each external prop, lest it get stale. But it brings other benefits,
Take all together, definitely a significant step up from current approach of manually overriding 2 different methods as well as managing setup, teardown, and rebuilds all within the State. And seems fairly non-invasive from an architectural standpoint. |
Any news about this? |
This is one of the main motivators for the long-term Dart static metaprogramming macros effort. dart-lang/language#1482 |
I was playing a bit with this subject, and found a somewhat hacky way to have a smoother syntax for chaining builders. You could override an operator (let's say The first post example could be rewriten like this: class SimpleExample extends StatelessWidget {
@override
Widget build(BuildContext context)
=> withTextEditingController() >> (context, controller1)
=> withTextEditingController() >> (context, controller2) {
return Column(
children: <Widget>[
TextField(controller: controller1),
TextField(controller: controller2),
],
);
};
} Of course, it's a custom formatting, as dart formatter would add more indent. This is not a suggestion for a lib or a new syntax as it's a bit hacky, I just found it interesting and I thought it might fuel the discussion here. Full example is here. |
But expression macros dart-lang/language#1874 (which would be needed for this) are not part of the scope of the static metaprogramming proposal So the current static metaprogramming proposal doesn't solve this problem |
One step at a time. :-) |
If I may suggest something I don't think has been suggested. Adding one more extra layer of indirection by making a State that can manage multiple states at runtime can remove the duplication: class MyBuilderDecorator<T> {
MyBuilderDecorator({required this.name, required this.init, required this.dispose});
final String name;
final T Function() init;
final void Function(T) dispose;
}
class MyBuilder extends StatefulWidget {
const MyBuilder({Key? key, required this.builder, required this.decorators})
: super(key: key);
final Widget Function(BuildContext, Map<String, Object>) builder;
final List<MyBuilderDecorator> decorators;
@override
MyBuilderState createState() => MyBuilderState();
}
class MyBuilderState extends State<MyBuilder> {
late TextEditingController textEditingController;
Map<String, Object> resources = {};
@override
void initState() {
super.initState();
for (MyBuilderDecorator decorator in widget.decorators) {
resources[decorator.name] = decorator.init();
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
for (MyBuilderDecorator decorator in widget.decorators) {
properties
.add(DiagnosticsProperty(decorator.name, resources[decorator.name]));
}
}
@override
void dispose() {
for (MyBuilderDecorator decorator in widget.decorators) {
decorator.dispose(resources[decorator.name]!);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(context, resources);
}
}
class Example extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MyBuilder(
decorators: [
MyBuilderDecorator(
name: 'controller1',
init: () => TextEditingController(text: 'Hello world'),
dispose: (x) => x.dispose()),
MyBuilderDecorator(
name: 'controller2',
init: () => TextEditingController(text: 'Hello world'),
dispose: (x) => x.dispose()),
],
builder: (context, resources) {
return Column(
children: <Widget>[
TextField(
controller: resources['controller1'] as TextEditingController),
TextField(
controller: resources['controller2'] as TextEditingController),
],
);
},
);
}
} The drawback is that there is information that would be nice to validate at compile-time but we are validating it at runtime (the names of the resources must match creation and retrieval and the type of the resource). The benefit is that you aren't bloating the State and Element trees, you are removing the duplication and you don't have crazy levels of indentation. You could even remove the duplication even further by factoring out functions that return |
In which way do you thing static meta programming will solve elegantly this issue ? |
@bouraine That remains to be seen. In general though the goal is to allow for something that's a cross between Lisp templates and code generation, which would potentially enable some interesting ways to reduce the boilerplate. |
fwiw, my It doesn't deal with changing dependencies as well as hooks, but it does mostly solve the issue with redundant logic across state. I ended up taking a similar approach to |
This comment was marked as duplicate.
This comment was marked as duplicate.
This issue is assigned but has had no recent status updates. Please consider unassigning this issue if it is not going to be addressed in the near future. This allows people to have a clearer picture of what work is actually planned. Thanks! |
Current plan contains to be to rely on Dart macros to begin to address this, but we are a long way from macros being available. |
I'm also looking forward Dart Macros @Hixie ! But I need to share that I'm a little worried about it. Sorry if what I said didn't help with a topic. I just wanted to expose what I believe to improve the framework <3 |
Hello there! 👋🏼 Say we'll have macros / comptime / augmentations / etc. in the next few years. Lately, almost every client-side framework is using / considering signals (e.g. Vue3's composables, Svelte's runes, Solid' signals, etc.) to reuse stateful logic in their application. What about Flutter? Can / do you want to follow a similar path? Is there a clear, official stance or vision for this framework? |
From my understanding of previous discussions, the plan was to evolve metaprogramming after its initial release to handle code-generation inside functions |
The first preview version of macros seems to have been released. How will this impact state management? |
AFAIK you could replace stateful widget with a macros simpler solution. But you can't do hooks or more advanced things with macros (yet?). |
I don't want to add noise to this conversation (the questions have been asked already), but since my last comment, even Angular adopted signals. This increments the amount of (major) frameworks that adopted KnockoutJs's approach towards reactivity to 4 (Vue, Solid, Svelte, Angular). I think I see a pattern in the client-side community, in general; most of the modern frameworks are proposing an API that:
In 2024, imho, these are not just classifiable as a "ephemeral trend" or as a dubious pattern anymore. Can macros bring us there (or somewhere close), somehow? |
Vue moved away from signals. It was an early adopter, but now uses something closer to ValueNotifiers & Riverpod than signals. I don't like signals. In particular I don't like the idea of "magically track all getters used inside a function" combined with how it's very common for folks to make signals "global". Ultimately, macros don't really help us here. For signals specifically, we don't need macros. A game changer would be "statement/expression macros", were we can apply macros inside a function instead of on the whole function. |
@rrousselGit I think you misunderstood, or I used the wrong nouns. I agree with signals "hiding too much". When I cited "signals", above, I didn't praise an API with "hidden reactivity nature" (e.g. see Svelte v<5, which gave their users quite the DX papercuts). This is quite the opposite of e.g. const [ a, setA ] = useState(0);
const b = a + 1; // on rebuilds, b is implicitly updated This leads to even weirder apis, e.g. What I wanted to say is: maybe, in retrospect, the fact that Flutter didn't "hastily adopt" a react-hooks-like-api might have been a "good" choice (we basically "skipped" one whole set of innovations); nonetheless, given the problem above, a solution space should be explored. The rest of the world kept exploring. Even Angular settled for these patterns.
I tried and failed to write something that looked similar to Vue's composable APIs. I ended up realizing I'm not smart enough to ship this one (skill issue btw). Jokes aside, I'm blown away at the fact that these APIs are not inherently offered by Flutter. At the end of day, this doesn't matter; what I'm concerned of is that this discussion is becoming "stale", with no real updates from people with a bigger brain than mine (we pointed out that macros wont help this one out specifically a while ago). |
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. Compose has various interesting solutions to "useState" and "StreamBuilder" using custom language features. |
.
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 nestedbuild
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 multipleStatefulWidget
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 alsoAnimationController
, 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:
disposed the controller when the
State
is disposed:doing whatever we want with that variable inside
build
.(optional) expose that property on
debugFillProperties
: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:
dispose
)The Mixin issue
The first attempt at factorizing this logic would be to use a mixin:
Then used this way:
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 ourbuild
method can use it freely.Such a widget would be usually implemented this way:
Then used as such:
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 a
TextEditingControllerBuilder
inside another once, this drastically decrease the code readability: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 a
State
life-cycles to perform some operation on those controllers, then we will need aGlobalKey
to access them. For example:The text was updated successfully, but these errors were encountered: