Skip to content
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

Open
rrousselGit opened this issue Mar 2, 2020 · 524 comments
Open

Reusing state logic is either too verbose or too difficult #51752

rrousselGit opened this issue Mar 2, 2020 · 524 comments
Labels
c: new feature Nothing broken; request for a new capability c: proposal A detailed proposal for a change to Flutter customer: crowd Affects or could affect many people, though not necessarily a specific customer. dependency: dart Dart team may need to help us framework flutter/packages/flutter repository. See also f: labels. P3 Issues that are less important to the Flutter project team-framework Owned by Framework team triaged-framework Triaged by Framework team

Comments

@rrousselGit
Copy link
Contributor

rrousselGit commented Mar 2, 2020

.

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 multiple TextEditingController, 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 a TextEditingControllerBuilder 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 and Element instance.

  • It is difficult to use the TextEditingController outside of build.
    If we want a State life-cycles to perform some operation on those controllers, then we will need a GlobalKey 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);
          },
        );
      }
    }
@rrousselGit
Copy link
Contributor Author

cc @dnfield @Hixie
As requested, that's the full details on what are the problems solved by hooks.

@dnfield
Copy link
Contributor

dnfield commented 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:

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?

@rrousselGit
Copy link
Contributor Author

rrousselGit commented 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.

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:

  • SingleTickerProviderStateMixin which has logic inside didChangeDependencies.
  • AutomaticKeepAliveClientMixin, which relies on super.build(context)

@rrousselGit
Copy link
Contributor Author

There are many examples in the framework where we want to reuse state logic:

  • StreamBuilder
  • TweenAnimationBuilder
    ...

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:

  • converting the widget to stateful
  • manually listen to the stream in initState+didUpdateWidget+didChangeDependencies
  • cancel the previous subscription on didChangeDependencies/didUpdateWidget when the stream changes
  • cancel the subscription on dispose

That's a lot of work, and it's effectively not reusable.

@TahaTesser TahaTesser added a: quality A truly polished experience framework flutter/packages/flutter repository. See also f: labels. labels Mar 2, 2020
@dnfield dnfield removed the a: quality A truly polished experience label Mar 2, 2020
@Hixie Hixie added P3 Issues that are less important to the Flutter project c: new feature Nothing broken; request for a new capability labels Jul 27, 2020
@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

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

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
Copy link
Contributor

Hixie commented Jul 28, 2020

With NNBD (specifically with late final so that initiializers can reference this) we'll be able to do something like this:

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.

@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

Widgets hides the complexity that users have to think about.

What do they hide?

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jul 28, 2020

The problem is not the number of lines, but what these lines are.

StreamBuilder may be about as many lines as stream.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:

  • Can the stream change over time? If we forgot to handle that, we have a bug.
  • Did we forget to close the subscription? Another bug
  • What variable name do I use for the subscription? That name may not be available
  • What about testing? Do I have to duplicate the test? With 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 mistake
  • If we listen to two streams at once, we now have multiple variables with very similar names polluting our code, it may cause some confusion.

@rrousselGit
Copy link
Contributor Author

What do they hide?

  • FutureBuilder/StreamBuilder hides the listening mechanism and keeps track of what is the current Snapshot.
    The logic of switching between two Future is fairly complex too, considering it doesn't have a subscription.close().
  • AnimatedContainer hides the logic of making a tween between the previous and new values.
  • Listview hides the logic of "mount a widget as it appears"

apps generally don't need to worry about adding state to the debug properties

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)

@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

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.

The problem is not the number of lines, but what these lines are.

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?

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jul 28, 2020

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.

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.

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 TweenAnimationBuilder/AnimatedContainer/... do.
The logic is not hidden

In the end, I think animations are a good comparison. Animations have this concept of implicit vs explicit.
Implicit animations are loved because of their simplicity, composability and readability.
Explicit animations are more flexible, but more complex.

When we translate this concept to listening to streams, StreamBuilder is an implicit listening, whereas stream.listen is explicit.

More specifically, with StreamBuilder you cannot forget to handle the scenario where the stream changes, or forget to close the subscription.
You can also combine multiple StreamBuilder together

stream.listen is slightly more advanced and more error-prone.

Builders are powerful to simplify the application.
But as we agreed on previously, the Builder pattern is not ideal. It's both verbose to write and to use.
This issue, and what hooks solve, is about an alternate syntax for *Builders

For example, flutter_hooks has a strict equivalent to FutureBuilder and StreamBuilder:

Widget build(context) {
  final AsyncSnapshot<T> snapshot = useStream(stream);
}

In the continuation, AnimatedContainer & alike could be represented by a useAnimatedSize / useAnimatedDecoractedBox / ... such that we have:

double opacity;

Widget build(context) {
  final double animatedOpacity = useAnimatedDouble(opacity, duration: Duration(milliseconds: 200));
  return Opacity(
    opacity: animatedOpacity,
    child: ...,
  );
}

@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

My point was that we cannot criticize something like hooks with "it hides logic",

That's not the argument. The argument is "it hides logic that developers should be thinking about".

@rrousselGit
Copy link
Contributor Author

Do you have an example of such logic that the developers should be thinking about?

@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

Like, who owns the TextEditingController (who creates it, who disposes of it).

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jul 28, 2020

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.

@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

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.

@rrousselGit
Copy link
Contributor Author

rrousselGit commented Jul 28, 2020

It looks like your argument is caused more by a lack of understanding on what hooks do rather than a real issue.
These questions have a clearly defined answer that is consistent with all hooks:

I have no idea what the lifecycle of the controller is with that code

Nor do you have to think about it. It is no longer the responsibility of the developer.

Does it last until the end of the lexical scope? The lifetime of the State

The lifetime of the State

Who owns it?

The hook owns the controller. It is part of the API of useTextEditingController that it owns the controller.
This applies to useFocusNode, useScrollController, useAnimationController, ...

In a way, these questions apply to StreamBuilder:

  • We don't have to think about the life-cycles of the StreamSubscription
  • The subscription lasts for the lifetime of the State
  • the StreamBuilder owns the StreamSubscription

@rrousselGit
Copy link
Contributor Author

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.

@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

It is no longer the responsibility of the developer

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.

@emanuel-lundman
Copy link

@Hixie
I don't think what @rrousselGit was saying was that they are the same thing but just that they have "the same rules and the same behaviour" regarding life cycle? Correct?

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).
Also. Hooks are composable so if you need to share state logic composed of multiple hooks you could make a new hook that uses other hooks and some extra logic and just use the one new hook.

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.
But if not there are a lot that could be done to speed up development a great deal if a thing like hooks or another solution for the same problems where available as a first class citizen.

@Hixie
Copy link
Contributor

Hixie commented Jul 28, 2020

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?

@satvikpendem
Copy link

@Hixie Hooks are nice to use because they compartmentalize the life cycle into a single function call. If I use a hook, say useAnimationController, I don't have to think about initState and dispose anymore. It removes the responsibility from the developer. I don't have to worry whether I disposed every single animation controller I created.

initState and dispose are fine for a single thing but imagine having to keep track of multiple and disparate types of state. Hooks compose based on the logical unit of abstraction instead of spreading them out in the life cycle of the class.

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 flutter_hooks package perhaps, and see how it feels. I say this with all respect, as a user of Flutter I have run into these issues that hooks provide solutions to, as have others. I am unsure how to convince you that these problems truly exist for us, let us know if there's a better way.

@gaearon
Copy link

gaearon commented Jul 29, 2020

I'll add a few thoughts from the React perspective.
Pardon if they're not relevant but I wanted to briefly explain how we think about Hooks.

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 throw in a VM searches upwards for the closest catch block instead of you passing around errorHandlerFrame in code.

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 useState() is more use State() if we had the syntax. It would be a language feature. You can model something like Hooks with Algebraic Effects in languages that have effect tracking. So in that sense, they would be regular functions, but the fact that they "use" State would be a part of their type signature. Then you can think of React itself as a "handler" for this effect. Anyway, this is very theoretical but I wanted to point at prior art in terms of the programming model.

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 useState(), pass it as an input to a cusom useGesture(state), then pass that as an input to several custom useSpring(gesture) calls which give you staggered values, and so on. Each of those pieces is completely unaware of the others and may be written by different people but they compose well together because state and effects are encapsulated and get "attached" to the enclosing Component. Here's a small demo of something like this, and an article where I briefly recap what Hooks are.

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.
Happy to answer other questions.

@rrousselGit
Copy link
Contributor Author

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?

The late keyword makes things better, but it still suffers from some issues:

Such Property may be useful for states that are self-contained or that do not depend on parameters that can change over time. But it may get difficult to use when in a different situation.
More precisely, it lacks the "update" part.

For example, with StreamBuilder the stream listened can change over time. But there is no easy solution to implement such thing here, as the object is initialized only once.

Similarly, hooks have an equivalent to Widget's Key – which can cause a piece of state to be destroyed and re-created when that key changes.

An example of that is useMemo, which is a hook that cache an instance of object.
Combined with keys, we can use useMemo to have implicit data fetching.
For example, our widget may receive a message ID – which we then use to fetch the message details. But that message ID may change over time, so we may need to re-fetch the details.

With useMemo, this may look like:

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 messageId does not change, the data-fetching is not performed again.
But when the messageId changes, a new Future is created.


It's worth noting that I do not think flutter_hooks in its current state is refined for Dart. My implementation is more of a POC than a fully-fledged architecture.
But I do believe that we have an issue with code reusability of StatefulWidgets.

I didn't remember where, but I remember suggesting that hooks in the ideal world would be a custom function generator, next to async* & sync*, which may be similar to what Dan suggest with use State rather than useState

@esDotDev
Copy link

esDotDev commented Feb 13, 2022

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 onWidgetUpdate would already be memoizing it's inputs?

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,

  • improved readability, more dart-like and familiar
  • you can keep initState and StatefulWidgets
  • more robust, there are not really any "rules" to follow
  • access to oop composition/inheritence patterns (as opposed to pure functional)

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.

@MisterLight
Copy link

Any news about this?

@Hixie
Copy link
Contributor

Hixie commented Jul 6, 2022

This is one of the main motivators for the long-term Dart static metaprogramming macros effort. dart-lang/language#1482

@jyardin
Copy link

jyardin commented Jul 6, 2022

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 >>) that accept a lambda as its argument. That would kind of mimic the Kotlin's trailing lambda syntax.
This makes chaining builders a bit simpler, with less nesting.

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.

@rrousselGit
Copy link
Contributor Author

This is one of the main motivators for the long-term Dart static metaprogramming macros effort. dart-lang/language#1482

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

@Hixie
Copy link
Contributor

Hixie commented Jul 6, 2022

One step at a time. :-)

@gaaclarke
Copy link
Member

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 MyBuilderDecorator instances as well.

@bouraine
Copy link

This is one of the main motivators for the long-term Dart static metaprogramming macros effort. dart-lang/language#1482

In which way do you thing static meta programming will solve elegantly this issue ?

@Hixie
Copy link
Contributor

Hixie commented Oct 20, 2022

@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.

@esDotDev
Copy link

esDotDev commented Dec 5, 2022

fwiw, my stateful_props is at 1.0 and addresses some of these problems.
https://pub.dev/packages/stateful_props

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 reactives which seems to have been discontinued.

@jacobaraujo7

This comment was marked as duplicate.

@flutter-triage-bot flutter-triage-bot bot added team-framework Owned by Framework team triaged-framework Triaged by Framework team and removed triaged-framework Triaged by Framework team labels Jul 8, 2023
@flutter-triage-bot
Copy link

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!

@Hixie
Copy link
Contributor

Hixie commented Aug 7, 2023

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.

@Hixie Hixie added the dependency: dart Dart team may need to help us label Aug 7, 2023
@jacobaraujo7
Copy link
Contributor

I'm also looking forward Dart Macros @Hixie ! But I need to share that I'm a little worried about it.
One of the things that most confuse newbies is the overuse of approaches to state management, and I strongly believe, as stated above, that this would mitigate this problem.

Sorry if what I said didn't help with a topic. I just wanted to expose what I believe to improve the framework <3

@lucavenir
Copy link

lucavenir commented Sep 24, 2023

Hello there! 👋🏼
Going back at this after some time because of my curiosity.

Say we'll have macros / comptime / augmentations / etc. in the next few years.
How is Flutter going to exploit those to address this issue?

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?

@rrousselGit
Copy link
Contributor Author

From my understanding of previous discussions, the plan was to evolve metaprogramming after its initial release to handle code-generation inside functions

@Hixie Hixie removed their assignment Nov 29, 2023
@goderbauer goderbauer added the triaged-framework Triaged by Framework team label Dec 5, 2023
@yyliveyy
Copy link

The first preview version of macros seems to have been released. How will this impact state management?
CommunityToolkit.Mvvm of dotnet also seems to be a good solution.

@bernaferrari
Copy link
Contributor

bernaferrari commented Mar 29, 2024

AFAIK you could replace stateful widget with a macros simpler solution. But you can't do hooks or more advanced things with macros (yet?).

@lucavenir
Copy link

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:

  • abstracts away logic from the UI, more clearly, giving the developer some sort of "guidance" on how to organize code (good for beginners, and good for cutting this long list quite a bit)
  • makes reactivity explicit and granular (less burden on the developer's mind, e.g. "oh no, this widget unexpectedly rebuilt, why did this happen..?")
  • better tests: instead of widget testing a UI to verify logic and updates, one could test their signals in isolation, instead (example)

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?

@rrousselGit
Copy link
Contributor Author

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.
And for the things discussed in this thread, the initial release of macros won't do much.

A game changer would be "statement/expression macros", were we can apply macros inside a function instead of on the whole function.

@lucavenir
Copy link

lucavenir commented Mar 29, 2024

@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).
Instead, I praised a reactive API with explicit, testable and granular nature. In this sense, both Vue and Svelte are fully embracing this approach. Solid's signals are slightly less explicit. Angular does something similar.

This is quite the opposite of e.g. useState.

const [ a, setA ] = useState(0);
const b = a + 1;  // on rebuilds, b is implicitly updated

This leads to even weirder apis, e.g. useEffect (to date, I didn't understand if its api is encouraged or not).

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.

Ultimately, macros don't really help us here. For signals specifically, we don't need macros.
And for the things discussed in this thread, the initial release of macros won't do much.

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).

@rrousselGit
Copy link
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c: new feature Nothing broken; request for a new capability c: proposal A detailed proposal for a change to Flutter customer: crowd Affects or could affect many people, though not necessarily a specific customer. dependency: dart Dart team may need to help us framework flutter/packages/flutter repository. See also f: labels. P3 Issues that are less important to the Flutter project team-framework Owned by Framework team triaged-framework Triaged by Framework team
Projects
None yet
Development

No branches or pull requests