-
Notifications
You must be signed in to change notification settings - Fork 30.2k
Reusing state logic is either too verbose or too difficult #51752
Description
Related to the discussion around hooks #25280
TL;DR: It is difficult to reuse State logic. We either end up with a complex and deeply nested build method or have to copy-paste the logic across multiple widgets.
It is neither possible to reuse such logic through mixins nor functions.
Problem
Reusing a State logic across multiple StatefulWidget is very difficult, as soon as that logic relies on multiple life-cycles.
A typical example would be the logic of creating a TextEditingController (but also AnimationController, implicit animations, and many more). That logic consists of multiple steps:
-
defining a variable on
State.TextEditingController controller; -
creating the controller (usually inside initState), with potentially a default value:
@override void initState() { super.initState(); controller = TextEditingController(text: 'Hello world'); }
-
disposed the controller when the
Stateis 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
StatefulWidgetneeds multipleTextEditingController, then we cannot use the mixin approach anymore. -
The "state" declared by the mixin may conflict with another mixin or the
Stateitself.
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 aTextEditingControllerBuilderinside 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
StateandElementinstance. -
It is difficult to use the
TextEditingControlleroutside ofbuild.
If we want aStatelife-cycles to perform some operation on those controllers, then we will need aGlobalKeyto 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); }, ); } }