Skip to content

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

@rrousselGit

Description

@rrousselGit
Contributor

.

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);
          },
        );
      }
    }

Activity

rrousselGit

rrousselGit commented on Mar 2, 2020

@rrousselGit
ContributorAuthor

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

dnfield

dnfield commented on Mar 2, 2020

@dnfield
Contributor

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

rrousselGit commented on Mar 2, 2020

@rrousselGit
ContributorAuthor

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

rrousselGit commented on Mar 2, 2020

@rrousselGit
ContributorAuthor

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.

added
a: qualityA truly polished experience
frameworkflutter/packages/flutter repository. See also f: labels.
on Mar 2, 2020
added
P3Issues that are less important to the Flutter project
c: new featureNothing broken; request for a new capability
on Jul 27, 2020
Hixie

Hixie commented on Jul 28, 2020

@Hixie
Contributor

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

Hixie commented on Jul 28, 2020

@Hixie
Contributor

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

Hixie commented on Jul 28, 2020

@Hixie
Contributor

Widgets hides the complexity that users have to think about.

What do they hide?

rrousselGit

rrousselGit commented on Jul 28, 2020

@rrousselGit
ContributorAuthor

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

rrousselGit commented on Jul 28, 2020

@rrousselGit
ContributorAuthor

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)

549 remaining items

rrousselGit

rrousselGit commented on Mar 29, 2024

@rrousselGit
ContributorAuthor

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.

benthillerkus

benthillerkus commented on Feb 2, 2025

@benthillerkus
Contributor

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.

Soooo, what's the current plan?

lucavenir

lucavenir commented on Apr 30, 2025

@lucavenir

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

esDotDev commented on Apr 30, 2025

@esDotDev

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

bernaferrari commented on Apr 30, 2025

@bernaferrari
Contributor

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

escamoteur commented on Apr 30, 2025

@escamoteur
Contributor

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

benthillerkus commented on May 1, 2025

@benthillerkus
Contributor

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?

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

escamoteur commented on May 1, 2025

@escamoteur
Contributor

@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

benthillerkus commented on May 1, 2025

@benthillerkus
Contributor

@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

it doesn't really make sense to argue further if the position of the framework team is still "everything is fine, actually"

escamoteur

escamoteur commented on May 1, 2025

@escamoteur
Contributor

@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

benthillerkus commented on May 1, 2025

@benthillerkus
Contributor

@escamoteur honestly it's just not a problem in practice.

lucavenir

lucavenir commented on May 1, 2025

@lucavenir

Hooks have proven themselves in React and flutter_hooks

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 of FutureBuilder 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

escamoteur commented on May 1, 2025

@escamoteur
Contributor

@lucavenir did you check out watch_its API ? I think it offers most of hooks simplicity but has a way more intuitive API

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projectc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Fluttercustomer: crowdAffects or could affect many people, though not necessarily a specific customer.dependency: dartDart team may need to help usframeworkflutter/packages/flutter repository. See also f: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Projects

    No projects

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @mraleph@bernaferrari@Hixie@esDotDev@gaearon

        Issue actions

          Reusing state logic is either too verbose or too difficult · Issue #51752 · flutter/flutter