Flutter FutureBuilder Rebuilding on Every Parent setState: Root Causes and Fixes
You add a FutureBuilder to fetch some data, everything looks fine in testing, then you notice: every time any parent widget calls setState, your network request fires again. The loading spinner flashes, the UI jumps, and your server logs show duplicate calls. This is one of the most common Flutter performance traps, and the fix is simpler than you might think.
What You'll Learn
- Why
FutureBuilderrestarts itsFutureon every widget rebuild - The exact mistake that causes the problem (and why it's easy to make)
- Four concrete patterns to fix it, from simple to scalable
- Edge cases and gotchas that trip up even experienced Flutter developers
The Problem: Your Future Resets Every Time the Widget Rebuilds
Consider a typical setup: you have a StatefulWidget that holds some local state β maybe a toggle, a counter, or a text field β and somewhere in its build method you render a FutureBuilder that loads user data from an API. You tap a button, setState runs, and suddenly the entire data fetch starts over. The spinner reappears, your cached content disappears, and your API gets hammered.
This isn't a bug in Flutter. It's a misunderstanding of how FutureBuilder tracks its Future.
Why FutureBuilder Works This Way
FutureBuilder holds an internal reference to the Future you pass it. When the widget rebuilds, Flutter compares the new Future reference to the old one. If the reference is different β even if the two futures do the same work β FutureBuilder treats it as a brand-new future, resets its ConnectionState to waiting, and discards whatever snapshot it had.
The Flutter documentation describes this explicitly: if the future argument changes between builds, the old future is discarded and the new one is subscribed to. This is intentional. It lets you swap out a future dynamically. The problem is that most developers accidentally trigger this on every build without meaning to.
The Most Common Mistake: Creating the Future Inside build()
Here's the pattern that causes the problem:
class UserProfileScreen extends StatefulWidget {
@override
State<UserProfileScreen> createState() => _UserProfileScreenState();
}
class _UserProfileScreenState extends State<UserProfileScreen> {
bool _showDetails = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
FutureBuilder<User>(
// β This creates a NEW Future object on every build call.
future: fetchUser(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return Text(snapshot.data!.name);
},
),
ElevatedButton(
onPressed: () => setState(() => _showDetails = !_showDetails),
child: const Text('Toggle Details'),
),
],
);
}
}
Every time the button is pressed, setState triggers build. build calls fetchUser(), which returns a new Future instance. FutureBuilder sees a new reference, drops its previous result, and starts over. The data reload is not a side effect β it's exactly what you asked Flutter to do.
Fix 1: Store the Future in State Using initState
The cleanest fix for most cases is to create your Future once in initState and store it as an instance variable. build then passes the same reference every time.
class _UserProfileScreenState extends State<UserProfileScreen> {
bool _showDetails = false;
// β
Future is created once and stored.
late Future<User> _userFuture;
@override
void initState() {
super.initState();
_userFuture = fetchUser();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
FutureBuilder<User>(
future: _userFuture, // Same reference on every build.
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return Text(snapshot.data!.name);
},
),
ElevatedButton(
onPressed: () => setState(() => _showDetails = !_showDetails),
child: const Text('Toggle Details'),
),
],
);
}
}
Now toggling _showDetails rebuilds the widget, but _userFuture stays the same object. FutureBuilder sees no change and keeps its resolved snapshot intact. The network call only happens once, when the widget is first inserted into the tree.
If you need to refresh the data on demand β say, a pull-to-refresh gesture β you can reassign _userFuture inside a setState call explicitly:
void _refresh() {
setState(() {
_userFuture = fetchUser(); // Intentional reset.
});
}
This is an intentional reference change, so FutureBuilder correctly reruns the fetch. You're in control.
If you run into widget lifecycle issues with async operations more broadly, the article on preventing setState calls after dispose in Flutter covers the complementary problem of cleaning up those references safely.
Fix 2: Use a Late Variable With Lazy Initialization
If the future depends on data that's only available after the widget's dependencies are resolved (for example, it needs something from context), initState won't work because you can't use context there. A common pattern in that case is to use didChangeDependencies instead, with a guard to run only once:
class _UserProfileScreenState extends State<UserProfileScreen> {
Future<User>? _userFuture;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Guard ensures we only assign once.
_userFuture ??= fetchUser(context.read<AuthService>().userId);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: _userFuture,
builder: (context, snapshot) {
// ... same as before
},
);
}
}
The ??= operator assigns only if _userFuture is currently null. Since didChangeDependencies can be called more than once (whenever an InheritedWidget it depends on changes), this guard is essential to avoid repeated fetches.
Fix 3: Move the Future Up the Tree With a Provider or InheritedWidget
The initState pattern works for a single widget, but it breaks down when multiple widgets need the same data or when the fetching logic should outlive the widget's lifetime. In those cases, the right move is to lift the future out of the widget entirely.
With a package like Riverpod, you define a FutureProvider at the app level. The provider handles caching automatically β it fires the future once and returns the cached result to every widget that listens, regardless of how many times those widgets rebuild.
// Defined outside any widget.
final userProvider = FutureProvider<User>((ref) async {
return fetchUser();
});
// Inside a ConsumerWidget:
class UserProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncUser = ref.watch(userProvider);
return asyncUser.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (user) => Text(user.name),
);
}
}
You get caching, error handling, and rebuild isolation β the widget rebuilds only when userProvider's state actually changes, not on every parent setState. This is the same family of problem that React developers solve by moving state out of components, which the article on stopping unnecessary React Context re-renders covers in detail if you work across both ecosystems.
Fix 4: Cache the Result, Not the Future
Sometimes you don't want to use a state management package, but you also don't want to re-fetch on every new widget instantiation. In those cases, you can cache the resolved result at a scope above the widget β for example, in a service class or a simple static map.
class UserCache {
static final Map<String, User> _cache = {};
static Future<User> getUser(String userId) async {
if (_cache.containsKey(userId)) {
return _cache[userId]!;
}
final user = await fetchUserFromApi(userId);
_cache[userId] = user;
return user;
}
}
Now even if FutureBuilder creates a new Future on rebuild, the inner logic resolves almost instantly from the in-memory cache. You still pay the overhead of a new Future object and a reference comparison inside FutureBuilder, so the spinner may flash briefly. Pairing this with the initState pattern eliminates even that.
This is a good fit for data that changes infrequently and is expensive to fetch β user profiles, configuration, feature flags. For data that changes often, a proper reactive state management approach is cleaner.
Common Pitfalls to Watch Out For
Passing an async lambda directly
A variation of the same mistake looks like this:
FutureBuilder<User>(
// β Also creates a new Future on every build.
future: () async { return fetchUser(); }(),
builder: ...,
)
Immediately invoked async lambdas create a new Future every time build runs, just like calling the function directly. Store the future in state instead.
Rebuilding parent widgets unintentionally
Sometimes the repeated rebuild isn't triggered by a setState you wrote. A parent widget using an AnimationController, a Stream-driven rebuild, or an InheritedWidget update can cascade down and trigger your widget's build method. The fix is the same β keep the Future reference stable β but knowing the source helps you debug faster. Use Flutter's widget rebuild tracking in DevTools to see which widget is triggering the chain.
Forgetting to handle the null snapshot state
When you pass a nullable Future<T>? to FutureBuilder and it's null, the builder fires with ConnectionState.none and a null snapshot. Many developers only handle waiting and data, which leaves a blank screen in that edge case. Always handle all four ConnectionState values, or at minimum add a fallback for the none case.
Using FutureBuilder when StreamBuilder is the right tool
If your data source pushes updates β a Firestore document, a WebSocket feed, a periodic poll β FutureBuilder is the wrong abstraction. It resolves once and stops. Use StreamBuilder instead. The same rebuild rules apply: store your Stream reference in state, not in build.
Parallel reads creating duplicate requests
If two sibling widgets both create their own FutureBuilder calling the same endpoint independently, you'll fire duplicate requests even if each widget handles its own future correctly. This is where lifting state up or using a shared provider pays off most. One request, one result, shared across the tree.
Flutter's UI thread can also be a source of subtle performance bugs beyond just rebuilds. The article on fixing Flutter Platform Channel calls that block the main UI thread is a useful companion read if you're tracking down jank.
Wrapping Up
The root cause of FutureBuilder restarting on every parent rebuild is almost always a new Future object being created inside the build method. Flutter isn't misbehaving β it's doing exactly what you told it to do. Here are your concrete next steps:
- Move your future assignment to
initStatefor the simplest cases. This is the fix you should reach for first. - Use
didChangeDependencieswith a null guard (??=) when the future depends on inherited data fromcontext. - Adopt a
FutureProvider(Riverpod) or equivalent when the data is shared across multiple widgets or needs to outlive a single screen's lifecycle. - Open Flutter DevTools and enable the widget rebuild counter. Watching which widgets rebuild on each interaction will surface this pattern immediately in unfamiliar codebases.
- Audit your
FutureBuilderusage in existing code: search your project forFutureBuilderand check whether thefuture:argument is a method call or an inline async expression. Each one is a likely candidate for this bug.
Frequently Asked Questions
Why does my FutureBuilder show a loading spinner every time I tap a button?
This happens because the Future is being created inside the build method, so every setState call produces a new Future reference. FutureBuilder treats a new reference as a fresh future and resets to the loading state. Move the future assignment to initState to fix it.
Is it safe to assign a Future in initState for API calls?
Yes, initState is the correct place to kick off a one-time async operation and store its Future. The Future starts running immediately but the widget won't try to call setState until it's mounted, so it's safe as long as you guard against calling setState after dispose.
How do I refresh a FutureBuilder without rebuilding the whole widget?
Store the Future in a state variable and reassign it inside a setState call when you want to refresh. FutureBuilder will detect the new reference and restart the fetch. This gives you explicit control over when re-fetches happen rather than triggering them accidentally.
Can I use FutureBuilder inside a ListView or other scrolling widget?
You can, but you need to be careful. If the FutureBuilder is inside a widget that gets rebuilt on every scroll frame, you must ensure the Future reference stays stable. Store the Future in a parent StatefulWidget's state, or use a provider that caches the result outside the scroll context.
What's the difference between storing the Future versus caching the result?
Storing the Future in initState means the async operation runs once per widget lifecycle and FutureBuilder subscribes to the same promise each time. Caching the result stores the resolved data so even a new Future resolves instantly from memory. The initState pattern prevents redundant network calls; result caching provides a fallback when the Future reference cannot be stabilized.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!