Use Flutter’s FutureBuilder with care

Posted on 1 Comment

I learned this the hard way. I knew Flutter’s stateless and stateful widget lifecycles well. But I was ignorant.

I made a grave, mostly fatal, mistake of doing something like this:

FutureBuilder<List<Product>>(
  future: this.productRepo.getProducts(),
  builder: (context, snapshot) {
    ...
)

class ApiProductRepo implements ProductRepo {
  Future<List<Product>> getProducts() async {
    // Call an API to get products
    // Return products
  }
}

Do you see the graveness of my code? No? Check again, I’ll wait.

Okay. Maybe you’ve guessed it, maybe not. See the code below. This is the culprit:

future: this.productRepo.getProducts()

FutureBuilder’s official docs say:

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.

https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html

Commit the bolded line in memory. For like ever. Otherwise, you’ll end up in the same trap as I. Flutter makes app development so seamless and fun that at times you forget basic optimizations. In my case, I was using a third-party metered API and found myself hitting close to my daily quotas 4x sooner than anticipated. That’s because FutureBuilder was calling the API 4 times, once on each widget rebuild.

My fix was simple: convert my stateless widget to a stateful one and cache API response in state.

class ProductListScreen extends StatefulWidget {
  static const id = 'product_list';

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

class _ProductListScreenState extends State<ProductListScreen> {
  List<Product> products;

  /// Returns recipes in cache-first fashion.
  Future<List<Product>> _getProducts() async {
    if (this.products != null) {
      return this.products;
    }

    final productRepo = Provider.of<ProductRepository>(context);
    this.products = await productRepo.getProducts();
    return this.products;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FutureBuilder<List<Recipe>>(
        future: this._getProducts(),
        builder: (context, snapshot) {
          ...
        },
      ),
    );
  }
}

Follow this advice, and save tonnes of troubles later.