Skip to content

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

@rrousselGit

Description

@rrousselGit

.

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

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

    Issue actions