Stop Overengineering State Management in Flutter (When setState Is Enough)
I once spent two days refactoring a simple settings screen to use BLoC. It had a toggle for dark mode, a dropdown for language, and a save button. Three pieces of state. By the time I was done, I had a SettingsBloc, a SettingsEvent sealed class with four variants, a SettingsState class, a SettingsRepository, and a builder widget wrapping every single element.
The original setState version was 40 lines. The BLoC version was 280 lines across 5 files.
I deleted all of it and went back to setState. The screen worked identically. Nobody could tell the difference. But now I could actually read my own code.
The Problem: One-Size-Fits-All Thinking
The Flutter community has a pattern of recommending a single state management solution for everything. "Just use Riverpod." "BLoC is the way." "GetX makes everything easy."
The result? Developers use industrial-strength state management for screens that have two booleans and a text field. They create providers for data that never leaves a single widget. They add complexity because they think complexity equals quality.
It doesn't. The right amount of architecture is the least amount that solves your actual problem.
How to Choose: The Complexity Framework
I use a simple decision tree that's never let me down:
Level 1: Widget-Local State → setState
Use setState when:
- The state belongs to one widget and its children
- No other widget in the tree needs to read or modify it
- The state doesn't survive navigation (it resets when you leave the screen)
// ✅ Perfect use of setState
class PasswordField extends StatefulWidget {
@override
State<PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<PasswordField> {
bool _obscured = true;
@override
Widget build(BuildContext context) {
return TextField(
obscureText: _obscured,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(_obscured ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscured = !_obscured),
),
),
);
}
}Nobody needs to know whether the password is visible except this one widget. Adding a provider for this is pure overhead.
More examples where setState is correct:
- Form field visibility toggles
- Expandable/collapsible sections
- Tab selection within a page
- Animation controllers
- Local loading/error states for a single button
Level 2: Shared Within a Screen → ValueNotifier + ListenableBuilder
Use ValueNotifier when:
- Multiple widgets on the same screen need to react to the same value
- You want to avoid rebuilding the entire widget tree
- The logic is simple (no async, no complex transformations)
// ✅ Shared state without a full provider
class CheckoutScreen extends StatefulWidget {
@override
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
final _selectedShipping = ValueNotifier<ShippingMethod>(ShippingMethod.standard);
@override
void dispose() {
_selectedShipping.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
ShippingPicker(selected: _selectedShipping),
// Only this widget rebuilds when shipping changes
ListenableBuilder(
listenable: _selectedShipping,
builder: (context, _) => OrderSummary(
shipping: _selectedShipping.value,
),
),
],
);
}
}ValueNotifier is built into Flutter — no packages needed. It does one thing well: holds a value and notifies listeners when it changes.
Level 3: Shared Across Screens → Provider / Riverpod
Use a state management package when:
- Multiple unrelated screens need to access the same data
- The state involves async operations (API calls, database reads)
- You need dependency injection (swapping implementations for testing)
- The state must survive navigation (user session, cart, settings)
// ✅ Riverpod for app-wide state with async
@riverpod
class CartNotifier extends _$CartNotifier {
@override
Future<Cart> build() async {
return await ref.read(cartRepositoryProvider).getCart();
}
Future<void> addItem(Product product) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final cart = await ref.read(cartRepositoryProvider).addItem(product);
return cart;
});
}
Future<void> removeItem(String productId) async {
state = await AsyncValue.guard(() async {
return await ref.read(cartRepositoryProvider).removeItem(productId);
});
}
}This makes sense here because:
- The cart needs to be accessible from the product page, cart page, and checkout page
- Adding/removing items involves API calls
- The cart state must persist across navigation
Level 4: Complex Domains → BLoC
Use BLoC when:
- You have complex event-driven logic (not just CRUD)
- Multiple events can trigger different state transitions
- You need event debouncing, throttling, or transformation
- The business logic is complex enough to warrant explicit event/state contracts
// ✅ BLoC for complex search with debouncing
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc({required this.repository}) : super(SearchInitial()) {
on<SearchQueryChanged>(
_onQueryChanged,
transformer: debounce(const Duration(milliseconds: 300)),
);
on<SearchFilterChanged>(_onFilterChanged);
on<SearchResultSelected>(_onResultSelected);
}
final SearchRepository repository;
Future<void> _onQueryChanged(
SearchQueryChanged event,
Emitter<SearchState> emit,
) async {
if (event.query.isEmpty) {
emit(SearchInitial());
return;
}
emit(SearchLoading());
try {
final results = await repository.search(
query: event.query,
filters: state.activeFilters,
);
emit(SearchResults(results: results, query: event.query));
} catch (e) {
emit(SearchError(message: e.toString()));
}
}
}BLoC earns its boilerplate here because you need event debouncing, multiple event types with different handlers, and clear state transitions for a complex flow.
The Mistakes That Cost You Hours
Mistake 1: Provider for Everything
// ❌ Why does a dialog need a provider?
final showDeleteDialogProvider = StateProvider<bool>((ref) => false);
// ✅ Just use local state
bool _showDeleteDialog = false;I've seen codebases with 50+ providers where half of them manage booleans for UI state that never leaves a single widget.
Mistake 2: BLoC for CRUD Screens
If your bloc's events are Load, Create, Update, Delete and your states are Loading, Loaded, Error — you've written 200 lines of boilerplate to do what a FutureProvider does in 10 lines.
// ❌ 5 files for a todo list
class TodoBloc extends Bloc<TodoEvent, TodoState> { ... }
sealed class TodoEvent {}
class LoadTodos extends TodoEvent {}
class AddTodo extends TodoEvent { final String title; ... }
class TodoState {}
class TodoLoading extends TodoState {}
class TodoLoaded extends TodoState { final List<Todo> todos; ... }
class TodoError extends TodoState { final String message; ... }
// ✅ Same thing in Riverpod
@riverpod
Future<List<Todo>> todos(TodosRef ref) async {
return ref.read(todoRepositoryProvider).getAll();
}Mistake 3: Not Using setState Because "It Doesn't Scale"
setState scales fine — within its scope. The question isn't "does it scale?" but "does this state need to be shared?" If the answer is no, setState is the correct choice regardless of app size.
I've worked on production Flutter apps with 100+ screens. Roughly 60% of stateful widgets use setState exclusively. The remaining 40% use providers or BLoC because the state is genuinely shared or complex.
A Real Decision Example
Let's say you're building an e-commerce app. Here's how I'd split state management:
| Screen/Feature | State | Approach | Why |
|------|-------|----------|-----|
| Product image carousel | Current slide index | setState | Local UI state |
| Product size selector | Selected size | setState | Local to one widget |
| Add-to-cart button | Loading state | setState | Local loading indicator |
| Shopping cart | Cart items, totals | Riverpod | Shared across 4 screens |
| Search | Query, filters, results | BLoC | Debouncing, complex events |
| User session | Auth token, profile | Riverpod | App-wide, async, DI needed |
| Bottom sheet expanded | isExpanded | setState | Trivial UI toggle |
| Checkout form | Form fields, validation | setState + TextEditingController | Local form state |
Notice how most of the app uses simple approaches. Only truly shared or complex state gets a package.
Key Takeaways
setStateis not a beginner tool. It's the correct tool for widget-local state, period.ValueNotifieris underrated. It handles shared-within-a-screen state without any packages.- Use providers for cross-screen state that involves async operations or dependency injection.
- Save BLoC for genuinely complex event-driven logic with debouncing or transformation.
- Count your files. If managing one boolean requires 3+ files, you've overengineered.
- The best architecture is the simplest one that solves your actual problem.
The Flutter community will always debate state management. But the developers who ship fast and maintain code well aren't the ones who pick the "best" package — they're the ones who pick the right tool for each specific problem.
Find me at: itszain.dev