Skip to content

pass_existing_future_to_future_builder

v0.8.0 Warning Widget Best Practices

Flags a FutureBuilder whose future: argument creates a new Future — a method call, a Future constructor, or an immediately invoked async closure.

build() can run many times per second: a parent rebuild, an inherited widget change, an animation tick. Every one re-evaluates the future: argument. If that argument creates a future, the builder sees a brand new object, resets to ConnectionState.waiting, and starts over.

The visible symptom is a spinner that flickers forever. The invisible one is worse: the underlying work runs again each time, so a network request inside that future can fire on every frame.

This rule is in the recommended preset, so it is on with preset: recommended and every preset above it. No configuration.

See also: FutureBuilder API docs

@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
// A new Future on every rebuild — restarts, and re-fetches, constantly
future: fetchUserData(),
builder: (context, snapshot) => Text('${snapshot.data}'),
);
}

Create it once in initState and hand the builder the same instance:

class _ProfilePageState extends State<ProfilePage> {
late final Future<String> _userData;
@override
void initState() {
super.initState();
_userData = fetchUserData();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _userData,
builder: (context, snapshot) => Text('${snapshot.data}'),
);
}
}

When the future depends on a widget property

Section titled “When the future depends on a widget property”

initState runs once, so a future keyed to a property has to be rebuilt when that property changes — which is exactly what didUpdateWidget is for:

class _ProfilePageState extends State<ProfilePage> {
late Future<String> _userData;
@override
void initState() {
super.initState();
_userData = fetchUserData(widget.userId);
}
@override
void didUpdateWidget(ProfilePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
_userData = fetchUserData(widget.userId);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _userData,
builder: (context, snapshot) => Text('${snapshot.data}'),
);
}
}

All three of these are reported, for the same reason:

// Don't
FutureBuilder<void>(
future: Future.delayed(const Duration(seconds: 1)),
builder: (context, snapshot) => const SizedBox(),
);
FutureBuilder<int>(
future: (() async => 1)(),
builder: (context, snapshot) => const SizedBox(),
);

Parentheses and a ! do not hide the call: future: (fetchUserData())! is still reported.

Only expressions that certainly allocate are reported: constructor calls, method invocations, and invoked closures. A bare identifier, a property access, a ternary, or anything unresolved is treated as an existing instance and left alone.

That means a getter which secretly creates a new future on each access is not flagged, even though it has the same problem:

// Not reported, but restarts on every rebuild all the same
Future<String> get data => fetchUserData();
Widget build(BuildContext context) => FutureBuilder<String>(
future: data,
builder: (context, snapshot) => Text('${snapshot.data}'),
);
many_lints.yaml
rules:
pass_existing_future_to_future_builder: false

To keep the rule on but skip certain paths, use per-rule exclude.