🔧 The Problem
If you've worked on a Flutter codebase for more than a few sprints, you've seen this pattern:
dart
class UserProfileScreen extends StatelessWidget {
final String userId;
const UserProfileScreen({required this.userId, super.key});
Future _fetchUser() => UserRepository().getUser(userId);
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _fetchUser(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return UserCard(user: snapshot.data!);
},
);
}
}
It looks harmless. It even looks idiomatic — it's in every Flutter tutorial. But _fetchUser() is called inside build(), which means it reruns on every rebuild that touches this widget. Scroll the parent ListView, trigger a setState two levels up, rotate the device — and congratulations, you just refetched the user, flashed a loading spinner over good data, and maybe hit your API rate limit.
The usual "fix" is to hoist the future into a late final field or initState, which helps — until the widget gets rebuilt by its parent with a new key, or the userId changes and nothing refreshes because the future was already memoized. Now you've got stale data instead of redundant fetches. Either way, the async lifecycle is coupled to the widget lifecycle, and widget lifecycles are not a reliable place to put business logic.
🕵️ Why This Keeps Biting Teams
The core issue isn't FutureBuilder itself — it's a well-built widget. The issue is where the future is created. Widgets rebuild for reasons that have nothing to do with your data: theme changes, MediaQuery updates, a sibling's setState, hot reload. None of those should restart a network call, but if the future lives in build(), they will.
The real fix is architectural: async operations belong in a layer that outlives individual widget rebuilds and has an explicit, observable lifecycle. That's exactly what Riverpod and Bloc are for — they just get there differently.
🧩 Riverpod: AsyncValue as a First-Class Citizen
Riverpod's FutureProvider (or AsyncNotifier for anything more than a one-shot fetch) moves the async call out of the widget tree entirely. The provider is keyed by its arguments, cached, and only re-executes when its dependencies actually change.
dart
@riverpod
Future user(UserRef ref, String userId) {
return ref.watch(userRepositoryProvider).getUser(userId);
}
dart
class UserProfileScreen extends ConsumerWidget {
final String userId;
const UserProfileScreen({required this.userId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider(userId));
return userAsync.when(
data: (user) => UserCard(user: user),
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
);
}
}
The win here is subtle but important: ref.watch(userProvider(userId)) doesn't re-run the future on rebuild. The provider is cached against the userId argument. If a parent rebuilds and this widget rebuilds with it, Riverpod just hands back the existing AsyncValue — no new HTTP call. If userId changes, Riverpod recognizes it as a different provider instance and fetches fresh data automatically. You get request deduplication and cache invalidation almost for free, and AsyncValue.when forces you to handle loading/error/data explicitly, which eliminates the "forgot to check snapshot.hasError" class of bugs.
The trade-off: you're leaning on code generation (@riverpod) and a mental model of providers-as-dependency-graph, which has a real learning curve if your team hasn't used DI-flavored patterns before. Debugging "why did this provider rebuild" sometimes means reading ref.watch chains across multiple files.
🧱 Bloc: Explicit States for Explicit Transitions
Bloc takes a more ceremonial but arguably more auditable approach: you model every async phase as a discrete state, and a Cubit or Bloc owns the transition logic.
dart
sealed class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
final User user;
UserLoaded(this.user);
}
class UserError extends UserState {
final String message;
UserError(this.message);
}
class UserCubit extends Cubit {
final UserRepository repository;
UserCubit(this.repository) : super(UserInitial());
Future loadUser(String userId) async {
emit(UserLoading());
try {
final user = await repository.getUser(userId);
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
}
}
dart
class UserProfileScreen extends StatelessWidget {
final String userId;
const UserProfileScreen({required this.userId, super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => UserCubit(context.read())..loadUser(userId),
child: BlocBuilder(
builder: (context, state) => switch (state) {
UserLoading() || UserInitial() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => Text('Error: $message'),
},
),
);
}
}
Because loadUser is called once in create, not on every build, the fetch is decoupled from widget rebuilds the same way Riverpod decouples it — just via explicit lifecycle methods instead of dependency-graph caching. BlocProvider guarantees the Cubit instance (and its in-flight future) survives rebuilds of the subtree, as long as you don't recreate the provider higher up the tree.
The trade-off is verbosity: four state classes for one fetch, plus boilerplate if you need to refetch when userId changes (you'd typically do that with a didUpdateWidget check or a BlocListener reacting to a parent-level event). In exchange, you get a state machine that's trivially testable with bloc_test and a transition log you can replay for debugging — genuinely valuable in regulated or high-incident-rate codebases where "what sequence of states did we actually go through" matters.
⚖️ The Actual Trade-off
Both solve the same root problem — stop constructing futures inside build() — but they optimize for different failure modes:
- Riverpod optimizes for correctness with less code: caching, deduplication, and parameterized providers are handled by the framework. You pay for it with a steeper initial learning curve and less explicit control over state transitions.
- Bloc optimizes for auditability and testability: every state change is a named, inspectable event. You pay for it with more files and manual wiring for things like argument-based refetching.
If your team is small, iterates fast, and wants async logic colocated with minimal ceremony, Riverpod's AsyncNotifier usually wins. If you're on a larger team, need strict separation between "what happened" and "how the UI reacts," or already have Bloc conventions baked into code review checklists, stick with it — just make sure you're creating the Bloc/Cubit outside of build(), same rule as Riverpod providers.
What neither pattern tolerates is the original sin: constructing async work inside the widget's build() method. That's the actual anti-pattern, not FutureBuilder itself — FutureBuilder is just the symptom you see first.
🗣️ Over to You
Which pattern has actually survived contact with your production incidents — Riverpod's cached providers or Bloc's explicit state machine? Or have you found a third way (raw ChangeNotifier, signals, flutter_hooks) that handles this better than either?
Top comments (0)