The Issue with the most comments

Students who care about Flutter may often check out the status of Flutter on Github. Now the number of STAR is 10.4W, but the number of open issues has been hovering at 7K + for nearly a year. On the one hand, this shows that Flutter is really hot. On the other hand, the steady trend of Open issue does make many developers worried about the future of Flutter. You may have different views on this question, so I won’t go into it.

Among the 7K + open(and 37K +closed) issues, this one has the most comments:Reusing state logic is either too verbose or too difficult, 407 comments as of this writing. That’s more than double the number of the next most commented issue. The author of “issue” is@rrousselGitThis is the official recommended state management library for FlutterProviderThe author of the Book, alsoflutter_hookThe author.What kind of issue is so popular? Reusing state logic is either too cumbersome or too difficult. What is state logic, what is too much trouble and too difficult? Due to the limited space, the entire content of the issue will not be quoted here. If you are interested, you can click on the link above to see the full article. However, my feeling is that what this issue wants to express is closely related to us, the Flutter developers, and the current development mode may be completely changed in the future. So I hope you can pay attention early so that you can prepare for the changes in the future. The following is an introduction to the problem of state logic reuse.

State logic reuse problem

We all know that there are two types of widgets in the Flutter system: stateless StatelessWidget and stateful StatefulWidget. Widgets are immutable. If you need mutable State for the lifetime of an Element, you have to stuff mutable things into State. The variable state is really a function of time, S = f(t). If S is the state value, then the function f() is the state logic, and time t is the lifetime of the Element. The variable state value is the time function value of the state logic. The state logic we encounter in real development might fetch data from the network, load images, play animations, and so on. So the reuse state logic discussed here is how this f() can be reused across widgets.

Let’s first look at how native Flutter can be reused. Let’s assume that we have a special web request class that we implement, MyRequest, that we use in our app for all web requests. So a general implementation would look like this:

class Example extends StatefulWidget {
  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> {

  MyRequest _myRequest = MyRequest();

  @override
  void initState() {
    super.initState();
    _myRequest.start();
  }

  @override
  void dispose() {
    _myRequest.cancel();
    super.dispose(); }}Copy the code

We need to customize a StatefulWidget class and a corresponding State class. Instantiate MyRequest in State, initialize and dispose in initState and Dispose respectively.

To reuse, you need to repeat what you did above in other widgets. The situation can be slightly more complicated, as shown in the Example above: The Widget has no properties inside it, and its State is not dependent. So the above implementation is fine. But when our request requires an external pass in a user name, uerId. It might look something like this:

class Example extends StatefulWidget {
  // Add userId.
  final userId;
  
  const Example({Key key, @required this.userId})
      : assert(userId ! =null),
        super(key: key);

  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> {

  MyRequest _myRequest = MyRequest();

  @override
  void initState() {
    super.initState();
    _myRequest.start(widget.userId);
  }
  // The didUpdateWidget needs to be overridden. Redo the request when the userId changes
  @override
  void didUpdateWidget(Example oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.userId != oldWidget.userId) {
      _myRequest.cancel();
      _myRequest.start(widget.userId);
    }
  }

  @override
  void dispose() {
    _myRequest.cancel();
    super.dispose(); }}Copy the code

With the additional userId, we need to rewrite the didUpdateWidget. The reuse of state logic is more complex and tedious.

Furthermore, if there are multiple requests in State, the complexity takes on a new level. Adding/removing a MyRequest requires at least operations in initState, didUpdateWidget, dispose, etc. Because a StatefulWidget corresponds to a State, reuse is a piecemeal copy and paste operation. This is obviously tedious and bug-prone.

What is the solution

Through the above analysis. Now we can figure out what criteria the solution should meet, and let’s call the new solution a “module.” First, a “module” should contain a separate piece of state logic. One network request, one IO operation, etc. The “module” should be UI-independent, so it is best not to rely on external widgets inside the “module”.

Second, as we have seen, one reason for the complexity of the native approach is that a separate State logic is split up into State lifecycle functions. So it’s best to let the application handle the module’s life cycle callbacks without requiring the user to do so manually.

Third, “modules” can be combined to provide more complex state logic. That is, if the state logic could be expressed as S = f(t), then the combination would look something like S = F (a(t), t) or S = F (a(t),b(t), t). This is how widgets are put together.

Finally, there must be no unacceptable degradation in the performance of the new solution. Neither time (response) nor space (memory) should be significantly reduced compared to the native approach.

The following points are summarized:

  • Independent, “module” contains a separate state logic.
  • Self-management and automatic processinginitStateAnd so on life cycle.
  • Composable, “modules” can be combined to provide more complex state logic
  • Excellent performance. There should be no unacceptable deterioration in performance.

Possible solutions

Now that you have your goals in mind, look at the solutions discussed in the issue and see what their strengths and weaknesses are.

Mixin

The state logic with mixins might look something like this:

mixin MyRequestMixin<T extends StatefulWidget> on State<T> {
   MyRequest _myRequest = MyRequest();
   
   MyRequest get myRequest => _myRequest;

  @override
  void initState() {
    super.initState();
    _myRequest.start();
  }

  @override
  void dispose() {
    _myRequest.cancel();
    super.dispose(); }}Copy the code

It works like this:

class Example extends StatefulWidget {
  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> with MyRequestMixin<Example> {
  @override
  Widget build(BuildContext context) {
    final data = myRequest.data;
    return Text('$data'); }}Copy the code

For details on how mixins can be used to separate views from data, please refer to my other article.

  • The Mixin approach satisfies both independence and performance, but self-management is only partially satisfied. whenWidgetThere is no problem when Mixin does not contain the required parameters. But whenWidgetContains Mixin parameters, as described aboveuserId. The code is red:

  • There are also shortcomings in the ability to combine. aStateOnly one Mixin of the same type can be mixed in. So give aStateMixing in multiple state logic of the same type is not feasible.
  • Another drawback is conflicts when different mixins define the same properties.

Builder

Buidler mode in Flutter framework has actually has a lot of ready-made examples, such as StreamBuilder FutureBuilder etc. MyRequest state logic with Builder might look something like this:

class MyRequestBuilder extends StatefulWidget {

  final userId;

  final Widget Function(BuildContext, MyRequest) builder;

  const MyRequestBuilder({Key key, @required this.userId, this.builder})
      : assert(userId ! =null),
        assert(builder ! =null),
        super(key: key);

  @override
  _MyRequestBuilderState createState() => _MyRequestBuilderState();
}

class _MyRequestBuilderState extends State<MyRequestBuilder> {

  MyRequest _myRequest = MyRequest();

  @override
  void initState() {
    super.initState();
    _myRequest.start(widget.userId);
  }

  @override
  void didUpdateWidget(MyRequestBuilder oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.userId != oldWidget.userId) {
      _myRequest.cancel();
      _myRequest.start(widget.userId);
    }
  }

  @override
  void dispose() {
    _myRequest.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    returnwidget.builder(context, _myRequest); }}Copy the code

It works like this:

class Example extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MyRequestBuilder(
      userId: "Tom",
      builder: (context, request) {
        returnContainer(); }); }}Copy the code

Visible, Builder mode basically is to satisfy above those a few conditions. It is a feasible reuse method of state logic. But there are several other drawbacks:

  • Code readability drops when multiple Builders are combined:
class Example extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MyRequestBuilder(
      userId: "Tom",
      builder: (context, request1) {
        return MyRequestBuilder(
          userId: "Jerry",
          builder: (context, request2) {
            returnContainer(); }); }); }}Copy the code

The Builder mode is not really an elegant solution. The logic of state, which is a juxtaposition, is combined into a parent-child relationship. Instead of nested patterns, we want something like this:

MyRequest request1 = MyRequest(); MyRequest request2 = MyRequest(); .// Use request1 and request2
Copy the code

This is one of the drawbacks of the Builder model, which makes indentation very ugly if you have too many nested Builders.

  • inElementWe added nodes to the tree. There may be some impact on performance.
  • Finally, the state logic cannot be invisible outside of the Builder. The outer layerbuildThe function cannot be accessed directlyrequest1One workaround is to useGlobalKeyBut that adds to the complexity.

Properties/MicroState

The solution is to encapsulate the State logic into state-like classes called properties, which are centrally installed into the host State, which then automatically handles the Property lifecycle callbacks. The package looks like this:

// The Property interface, consistent with the State lifecycle callback
abstract class Property {

  void initState();

  void dispose();
}
/ / Property
class MyRequestProperty extends Property {

  MyRequest _myRequest = MyRequest();

  @override
  void initState() {
    _myRequest.start();
  }

  @override
  voiddispose() { _myRequest.cancel(); }}/ / the host State
abstract class PropertyManager extends State {
  // The reused state logic is stored here
  final properties = <Property>[];

  @override
  void initState() {
    super.initState();
    // Iterate over the callback initState
    for (final property inproperties) { property.initState(); }}@override
  void dispose() {
    super.dispose();
     // Iterate through the callback dispose
    for (final property inproperties) { property.dispose(); }}}Copy the code

The key to this pattern is a list of added properties in host State. The host then iterates over the Property in its own lifecycle callbacks and invokes their corresponding callback functions. As you can see, this approach meets the independence and performance requirements. Self-management works fine without relying on Widget properties, but when there are dependencies like userId, the host needs to override the didUpdateWidget to extract the dependent properties and then send them to the corresponding Property. The visible Property approach has similar pitfalls as mixins. In addition, in terms of combination, there is no problem when properties are side-by-side, but it is more troublesome to combine several properties into a larger Property.

Hooks

And finally, the subject of the highest commented issue, Hooks. If Hooks were introduced, MyRequest’s state logic reuse would look like this:

// StatefulWidget is no longer required
class MyRequestWidget extends HookWidget {

  final userId;
  const MyRequestBuilder({Key key, @required this.userId)
      : assert(userId ! =null),
        super(key: key);
        
  @override
  Widget build(BuildContext context) {
    // One function does everything
    final myRequest = useMyRequest(userId: userId);
    returnContainer(); }}Copy the code

Is it super simple in an instant? No initState didUpdateWidget and dispose lifecycle callback, no Builder that nested, not fragmentary, copy and paste, even StatefulWidget are no longer needed. Just add a line of the useXXX function to build. Independence, self-management, performance is not a problem, nor is composition a problem. Please refer to my previous article on the Use and Principles of Flutter Hooks.

The drawback is that Hooks are too radical and in some ways contradict the idea of Flutter. As you can see from the design of the State, every lifecycle callback is given to you, giving the developer control over what to do at any stage. And now? There’s no life cycle, there’s no State, all of this is replaced by useXXX in a build function. This can be scary for developers used to controlling the lifecycle. What’s going on behind the scenes? Could there be any unpredictable consequences? We always keep in mind that we should not call complex time-consuming functions in build functions. Build functions should be kept clean and only do build-related tasks. Other initialization, cleaning, and other tasks should be done in the corresponding callback. But the useXXX here seems to arrange all these jobs, which is not appropriate.

This is why this issue has been able to cover more than 400 stories in one go, because it is a clash between these two ideas (even OOP and FP).

What can we learn by watching

Usually when we learn a new technology, we read the documentation that someone else has written, we read the source code that someone else has written. Follow suit and write your own code, so down can only be said to use it. The documentation and source code are already finished products. You see the same product, but there may be a lot of drafts behind it. Why this design stands out, it must be through constant communication and iteration that it beats other competitors. What we see is that we know what the API is and maybe don’t know why. To understand why, you need to be involved in the design process, even if you can’t offer your own opinion, but you can definitely benefit from keeping an eye on the discussion.

  • Through the analysis of a problem, we can learn more information. Before, we may know one and not know the other, but through watching, we may learn the other.

  • By fighting the pros and cons of a solution, you can better understand its context, advantages and disadvantages.

  • By watching the two sides exchanging opinions (arguing), we can learn what kind of communication is constructive. The wrong way of communication will make the communication more and more deviate from the purpose of communication. Not only is it a waste of time, but it is also destructive to the team, organization, or community and should be avoided.

  • Onlookers can also learn how to control the direction of communication, keenly detect abnormalities in the process of communication, and take timely measures to ensure that communication is back on the right track.

Therefore, I suggest that you pay more attention to the new trends in the industry, not only to this specific issue, but also to learn knowledge that you can’t get from reading documents.

Finally, back to the issue of this article. I have no opinion on the two ideas behind hooks, but I suggest that you evaluate them for yourself, and maybe try out hooks for a page or two in your own projects. In addition to React, Vue, iOS SwiftUI, and Android Jetpack Compose all introduce implementations similar to hooks.

(Full text)