Fixing Flutter FutureBuilder Rebuilds That Fetch Data on Every Render
You open the network tab, and your Flutter app is firing the same API call five times in a row. Nothing obvious changed in the code β the button was tapped once, the route was pushed once. The culprit is almost always FutureBuilder recreating its Future on every render cycle.
This is one of the most common Flutter performance mistakes, and it bites developers at every experience level. The fix is not complicated, but you need to understand why it happens before the solution makes sense.
What you'll learn
- Why
FutureBuildertriggers duplicate fetches during rebuilds - How to anchor a
Futureto aStatefulWidget's lifecycle - How to use a
ValueNotifieror a simple state-management approach to avoid the problem entirely - Common gotchas that bring the bug back even after you think you've fixed it
Prerequisites
You should be comfortable with basic Flutter widgets and have used FutureBuilder at least once. Code examples use Dart 3 syntax. No third-party packages are required for the core fix, though the provider example references the provider package.
Why FutureBuilder Refetches on Every Rebuild
FutureBuilder does exactly what the name says: it builds a widget subtree based on the state of a Future. The problem is that it does not own or cache the Future β it only observes the one you hand it.
When a parent widget rebuilds (because its own state changed, because an ancestor called setState, or because the theme changed), your build method runs again. If the Future is created inline inside build, a brand-new Future is created on every pass. FutureBuilder sees a new object reference and starts the async work all over again.
// β This creates a new Future on every rebuild
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Product>>(
future: productRepository.fetchAll(), // called every build!
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
return ProductList(items: snapshot.data ?? []);
},
);
}Every time the widget rebuilds, fetchAll() fires. If fetchAll() makes an HTTP request, you get an HTTP request on every rebuild. That is the entire bug.
The Correct Fix: Store the Future in State
The standard solution is to move the Future out of build and into the widget's state object, initializing it once in initState. The Future lives as long as the widget does, and rebuilds no longer recreate it.
class ProductScreen extends StatefulWidget {
const ProductScreen({super.key});
@override
State<ProductScreen> createState() => _ProductScreenState();
}
class _ProductScreenState extends State<ProductScreen> {
late Future<List<Product>> _productsFuture;
@override
void initState() {
super.initState();
_productsFuture = productRepository.fetchAll(); // called once
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Product>>(
future: _productsFuture, // same Future reference every build
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return ProductList(items: snapshot.data ?? []);
},
);
}
}initState runs once when the state object is first inserted into the tree. After that, rebuilds reuse the existing _productsFuture field. FutureBuilder gets the same object reference every time and does not restart the async operation.
Handling Intentional Refreshes
Storing the future in state is great for the initial load, but you still need a way to let users refresh. The pattern is to replace _productsFuture inside setState, which triggers one rebuild with the new future.
void _refresh() {
setState(() {
_productsFuture = productRepository.fetchAll();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
body: FutureBuilder<List<Product>>(
future: _productsFuture,
builder: (context, snapshot) {
// ... same builder as before
},
),
);
}Calling setState here is deliberate β you want a rebuild so the loading indicator appears while the new fetch is in progress. This is different from the accidental rebuilds we are fixing.
Using didUpdateWidget for Parameter Changes
Sometimes the future depends on a parameter passed into the widget, such as a user ID or a search query. If the parent passes a new value, you need to detect that and re-fetch. didUpdateWidget is the right hook for this.
class UserProfileScreen extends StatefulWidget {
const UserProfileScreen({super.key, required this.userId});
final String userId;
@override
State<UserProfileScreen> createState() => _UserProfileScreenState();
}
class _UserProfileScreenState extends State<UserProfileScreen> {
late Future<UserProfile> _profileFuture;
@override
void initState() {
super.initState();
_profileFuture = userRepository.fetchProfile(widget.userId);
}
@override
void didUpdateWidget(UserProfileScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
setState(() {
_profileFuture = userRepository.fetchProfile(widget.userId);
});
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<UserProfile>(
future: _profileFuture,
builder: (context, snapshot) {
// builder logic
},
);
}
}The guard if (oldWidget.userId != widget.userId) is critical. Without it, any rebuild that causes didUpdateWidget to be called β even with the same userId β would trigger a new fetch.
The Provider Alternative
For screens that are complex, shared across multiple routes, or need the data to survive navigation, moving the async logic into a ChangeNotifier (or any other state-management solution) is cleaner than managing futures directly in widget state.
class ProductNotifier extends ChangeNotifier {
List<Product> products = [];
bool isLoading = false;
String? error;
Future<void> load() async {
isLoading = true;
error = null;
notifyListeners();
try {
products = await productRepository.fetchAll();
} catch (e) {
error = e.toString();
} finally {
isLoading = false;
notifyListeners();
}
}
}
You call load() once β typically in initState or in a button handler β and your widget tree listens for changes via Consumer or context.watch. The data outlives rebuilds, and you get a single source of truth that multiple widgets can read without triggering additional network calls.
Common Pitfalls That Bring the Bug Back
Creating the Future in a const constructor argument
Even if you store a future in state, if you pass a different function call as the future argument while keeping the stored one for something else, you reintroduce the problem. Always pass the stored field, never a method call, to the future parameter.
Using FutureBuilder inside a ListView.builder
Each scroll event that causes list items to rebuild will recreate the futures for those items if they are not stored. For per-item async data, either pre-fetch before building the list, or store the future inside an item-level StatefulWidget.
Async work in StatelessWidget
You cannot store mutable state in a StatelessWidget. If you try to work around this with top-level variables or singletons, you create a different category of bugs (shared state, stale data across widget trees). Convert to StatefulWidget or use a proper state-management layer.
Forgetting the late keyword initialization
Declaring late Future<X> _myFuture without assigning it in initState means you will get a LateInitializationError at runtime the first time build runs. Always pair late with an assignment in initState.
Calling setState unnecessarily
Calling setState for unrelated UI changes (toggling a boolean, updating a counter) while your FutureBuilder is watching a field you did not reassign is actually fine β the future reference stays the same. The bug only occurs when a new Future instance is handed to FutureBuilder.
Quick Diagnostic Checklist
If you suspect extra fetches, run through this list before diving into the code:
- Is the
futureargument toFutureBuildera stored field or a function call? - Is the stored field initialized in
initState, not inbuild? - If the future depends on widget parameters, is
didUpdateWidgetguarding re-fetches correctly? - Is the widget a
StatefulWidget, not aStatelessWidget? - Are there ancestor
setStatecalls that rebuild this subtree unexpectedly?
Wrapping Up
The root cause is always the same: a new Future object is being passed to FutureBuilder on each render. Fix that, and the duplicate fetches stop immediately.
Here are the concrete steps to take next:
- Audit your existing screens. Search your codebase for
FutureBuilderand check whether thefutureargument is a field or a call expression. Any call expression is a potential bug. - Migrate inline futures to
initState. Convert the offendingStatelessWidgets toStatefulWidgets and move the future assignment toinitState. - Add
didUpdateWidgetguards for any screen whose future depends on incoming widget parameters. - Consider a state-management solution (provider, Riverpod, Bloc) for screens where the fetched data needs to be shared across the widget tree or survive route transitions.
- Use Flutter DevTools' network tab to verify the fix worked β watch the request count before and after refactoring to confirm you have eliminated the duplicates.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!