Gary Oldfaber
Gary Oldfaber

Reputation: 1712

Flutter StatefulWidget causing multiple HTTP re-requests

This is probably caused by something stupid I'm doing or forgetting to do. Below is a full working code example (as of Dec5 2019) for Flutter and an HTTP echo server (httpbin) for reproducing this problem.

Run httpbin:

docker run -p 1234:80 kennethreitz/httpbin

Then load the code into a new Flutter app. On a fresh load of the app, click Route A in the drawer and you get the following printed to console:

flutter: Loaded <RouteA> (Stateful)
flutter: Got data from Route A 1 times.

Click Route B and you get:

flutter: Loaded <RouteB> (Stateful)
flutter: Got data from Route B 1 times.
flutter: Loaded <RouteA> (Stateful)
flutter: Got data from Route A 2 times.

(it reloads Route A, which performs another HTTP request).

Load Route B again and you get:

flutter: Loaded <RouteB> (Stateful)
flutter: Got data from Route B 2 times.
flutter: Loaded <RouteB> (Stateful)
flutter: Loaded <RouteA> (Stateful)
flutter: Got data from Route A 3 times.
flutter: Got data from Route B 3 times.

Load Route B another time and you get:

flutter: Loaded <RouteB> (Stateful)
flutter: Got data from Route B 4 times.
flutter: Loaded <RouteB> (Stateful)
flutter: Loaded <RouteA> (Stateful)
flutter: Loaded <RouteB> (Stateful)
flutter: Got data from Route B 5 times.
flutter: Got data from Route B 6 times.
flutter: Got data from Route A 4 times.

Each of these loads corresponds to an HTTP request, so if the app has been open long enough, it might make 100 HTTP requests for a single Stateful widget load.

Note that if you load Route C (a Stateless widget) it only ever loads once.

This obviously has something to do with how StatefulWidgets are reloaded, but I'm stuck and haven't been able to find posts with a similar problem online.

Why is Flutter doing this? How can I make it behave like a StatelessWidget for HTTP requests?

See code example below

/*
 * Flutter code for weird HTTP behavior with StatefulWidget
 *
 * Make sure you're also running httpbin locally with the following command:
 *
 *   docker run -p 1234:80 kennethreitz/httpbin
 */
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;


int routeAqueries = 0;
int routeBqueries = 0;
int routeCqueries = 0;


void main() {
  runApp(HttpDebug());
}


class HttpDebug extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'HomeDebug',
      home: HomeDebug(),
    );
  }
}


class HomeDebug extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      drawer: _Drawer(),
      body: Center(child: Text('Home')),
    );
  }
}


class RouteA extends StatefulWidget {
  @override
  _RouteAState createState() => _RouteAState();
}

class _RouteAState extends State<RouteA> {
  @override
  Widget build(BuildContext context) {
    print('Loaded <RouteA> (Stateful)');
    return Scaffold(
      appBar: AppBar(title: Text('Route A')),
      drawer: _Drawer(),
      body: FutureBuilder<String>(
        future: fetchRoute('routeA'),
        builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
          if (snapshot.hasData) {
            return Text('RouteA Data: ${snapshot.data}');
          } else if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          } else {
            return Text('Loading');
          }
        },
      ),
    );
  }
}


class RouteB extends StatefulWidget {
  @override
  _RouteBState createState() => _RouteBState();
}

class _RouteBState extends State<RouteB> {
  @override
  Widget build(BuildContext context) {
    print('Loaded <RouteB> (Stateful)');
    return Scaffold(
      appBar: AppBar(title: Text('Route B')),
      drawer: _Drawer(),
      body: FutureBuilder<String>(
        future: fetchRoute('routeB'),
        builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
          if (snapshot.hasData) {
            return Text('RouteB Data: ${snapshot.data}');
          } else if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          } else {
            return Text('Loading');
          }
        },
      ),
    );
  }
}


class RouteC extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('Loaded <RouteC> (Stateless)');
    return Scaffold(
      appBar: AppBar(title: Text('Route C')),
      drawer: _Drawer(),
      body: FutureBuilder<String>(
        future: fetchRoute('routeC'),
        builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
          if (snapshot.hasData) {
            return Text('RouteC Data: ${snapshot.data}');
          } else if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          } else {
            return Text('Loading');
          }
        },
      ),
    );
  }
}


class _Drawer extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Drawer(
        child: ListView(
          children: <Widget>[
            ListTile(
              title: Text('Home'),
              onTap: () => Navigator.push(
                context,
                MaterialPageRoute(builder: (context) => HomeDebug()),
              ),
            ),
            ListTile(
              title: Text('Route A'),
              onTap: () => Navigator.push(
                context,
                MaterialPageRoute(builder: (context) => RouteA()),
              ),
            ),
            ListTile(
              title: Text('Route B'),
              onTap: () => Navigator.push(
                context,
                MaterialPageRoute(builder: (context) => RouteB()),
              ),
            ),
            ListTile(
              title: Text('Route C'),
              onTap: () => Navigator.push(
                context,
                MaterialPageRoute(builder: (context) => RouteC()),
              ),
            ),
          ],
        )
    );
  }
}


Future<String> fetchRoute(String route) async {
  Map<String, int> routes = {
    'routeA': 200,
    'routeB': 201,
    'routeC': 202,
  };

  final response = await http.get('http://localhost:1234/status/${routes[route]}');

  if (response.statusCode == 200) {
    print('Got data from Route A ${++routeAqueries} times.');
    return 'Welcome to Route A';
  } else if (response.statusCode == 201) {
    print('Got data from Route B ${++routeBqueries} times.');
    return 'Welcome to Route B';
  } else if (response.statusCode == 202) {
    print('Got data from Route C ${++routeCqueries} times.');
    return 'Welcome to Route C';
  }
}

Upvotes: 1

Views: 2547

Answers (2)

Ezra
Ezra

Reputation: 1168

As chunhunghan mentioned, the fetch should take place in initState and not the build method (this documention and the two steps preceding it were helpful to me in understanding and fixing the number of requests.) Using the information in that link, I ended up with this for each stateful widget:

class _RouteAState extends State<RouteA> {
  Future<String> _post;

  @override
  void initState() {
    super.initState();
    _post = fetchRoute('routeA');
  }

  @override
  Widget build(BuildContext context) {
    // ...
        future: _post,

If I understand correctly, the number of requests isn't the only thing you're wanting to fix. Even with moving the fetch to initState, you'll still be seeing multiple flutter: Loaded <RouteX> (Stateful) being fired off for each navigation. This is because all the routes are still on the navigator stack, so the stateful ones have their build methods run for each route on the stack. The easiest patch to see the desired result would be to replace Navigator.push with Navigator.pushReplacement, but you might want something more elaborate, in order to prevent a back navigation from exiting the app.

There are quite a few other options for replacing routes, so be sure to see if any others fit your desired semantics better.

Upvotes: 1

chunhunghan
chunhunghan

Reputation: 54407

Reason
https://medium.com/saugo360/flutter-my-futurebuilder-keeps-firing-6e774830bc2
when rebuilt, the new widget has a different Future instance than the old one

https://github.com/flutter/flutter/issues/11426#issuecomment-414047398
didUpdateWidget of the FutureBuilder state is being called every time a rebuild is issued. This function checks if the old future object is different from the new one, and if so, refires the FutureBuilder. To get past this, we can call the Future somewhere other than in the build function.

https://docs.flutter.io/flutter/widgets/FutureBuilder-class.html
The future must have been obtained earlier, e.g. during State.initState, State.didUpdateConfig, or State.didChangeDependencies. It must not be created during the State.build or StatelessWidget.build method call when constructing the FutureBuilder. If the future is created at the same time as the FutureBuilder, then every time the FutureBuilder's parent is rebuilt, the asynchronous task will be restarted.
A general guideline is to assume that every build method could get called every frame, and to treat omitted calls as an optimization.

Solution
https://github.com/flutter/flutter/issues/11426#issuecomment-414047398
instead of having:

FutureBuilder(
  future: someFunction(),
  ....

We should have:

initState() {
  super.initState();
  _future = SomeFunction();
}

and then

FutureBuilder(
  future: _future,

And in your Drawer, you need to use pushReplacement

Upvotes: 1

Related Questions