A Flutter application can feel easy to extend during its first few screens. Then a profile update stops appearing elsewhere, a search displays outdated results, or a checkout draft disappears after navigation.
These problems often come from unclear state ownership, lifetime, and update rules.
Choosing Riverpod, Bloc, Provider, or setState does not automatically resolve those decisions. Flutter’s architecture guidance acknowledges multiple state-management options and recommends adapting architectural practices to the application’s requirements.
The following mistakes explain why manageable code becomes difficult to change, and how teams can establish clearer boundaries.
1. Moving Every Value Into Global State
A password visibility toggle rarely needs the same lifetime as an authenticated session.
Flutter distinguishes ephemeral state, usually contained within a widget, from application state, which may be shared across multiple parts of the app. Local state does not automatically require a separate state-management package.
Making temporary interactions globally accessible introduces unnecessary dependencies. It can also preserve values beyond the flow where they make sense.
A useful starting point is:
| State | Suggested owner |
|---|---|
| Expanded accordion section | Local widget |
| Unsaved field on one screen | Screen or form controller |
| Checkout draft shared across steps | Checkout-scoped state |
| Current session | Application-level session owner |
| Product records | Repository and associated cache |
The appropriate scope is the smallest one that supports all consumers and the required lifetime.
2. Combining Networking, Business Rules, and Rendering
A widget becomes difficult to maintain when it fetches data, calculates discounts, handles retries, and builds the interface.
Changing a business rule then risks changing rendering behavior. Testing a calculation may require constructing an entire screen.
Flutter’s architecture guide separates views, view models, repositories, and services by responsibility. Views render information and forward user interactions; other components manage presentation logic and data access.
An illustrative boundary might look like this:
abstract class CartRepository {
Future<void> updateQuantity(String productId, int quantity);
}
class CartController {
CartController(this.repository);
final CartRepository repository;
Future<void> changeQuantity(String productId, int quantity) {
if (quantity < 1) {
throw ArgumentError.value(quantity, 'quantity');
}
return repository.updateQuantity(productId, quantity);
}
}This is a responsibility example, not a complete reactive controller. Loading, errors, and notifications still need implementation.
The benefit is that quantity validation can be tested without rendering a cart screen.
3. Maintaining Multiple Sources of Truth
Suppose a cart stores its items, item count, subtotal, and discounted total independently. Every quantity change must update all four correctly.
Unless there is a deliberate caching requirement, values that can be calculated from authoritative data should usually be derived.
For example:
int get itemCount =>
items.fold(0, (total, item) => total + item.quantity);The same reasoning applies to customer records copied into several screen controllers. Copies can drift unless ownership and synchronization are explicit.
An editing draft is different: it intentionally separates unsaved changes from the saved record. It needs clear save, discard, and conflict behavior.
Flutter’s architecture guidance treats repositories as sources of truth for application data. That gives screens a common place to obtain and update records.
4. Exposing Mutable Collections
A controller can lose control of its state if callers receive a mutable list and modify it directly.
An encapsulated ChangeNotifier example prevents callers from changing the collection:
class Favorites extends ChangeNotifier {
final Set<String> _ids = {};
Set<String> get ids => Set.unmodifiable(_ids);
void add(String id) {
if (_ids.add(id)) {
notifyListeners();
}
}
}The file requires package:flutter/foundation.dart.
All additions now pass through a method that also emits a notification. Flutter’s simple state-management guide demonstrates this broader pattern of private mutable data, controlled methods, and listener notifications.
An unmodifiable collection protects its structure. If its elements are mutable objects, those objects still need their own safeguards.
5. Starting Requests Inside build()
This pattern can restart work whenever the parent rebuilds:
FutureBuilder(
future: repository.loadProducts(),
builder: (context, snapshot) {
// Render the snapshot.
return const SizedBox();
},
);Flutter’s FutureBuilder documentation explicitly says to obtain the future before build(), such as in initState, didUpdateWidget, or didChangeDependencies.
For a request independent of changing inputs:
late Future<List<Product>> _productsFuture;
@override
void initState() {
super.initState();
_productsFuture = widget.repository.loadProducts();
}The builder then receives _productsFuture.
If a category or repository changes, the request must be updated deliberately. Caching the initial future forever would introduce a different bug.
Treat rebuilds as opportunities to describe the UI, not implicit instructions to repeat network operations.
6. Ignoring Async Races and Disposal
A search for “flu” may start before a search for “flutter” but finish afterward. If both responses update the same state unconditionally, the older result can overwrite the newer one.
A useful implementation policy is to associate each request with an identifier and accept results only from the current request. Cancellation can also help where the client supports it.
Debouncing reduces request frequency; it does not guarantee response order.
Widget lifetime creates another problem. Flutter prohibits calling setState after disposal and recommends cancelling work that could trigger it rather than relying only on a mounted check. The setState callback must also remain synchronous.
Every asynchronous operation needs answers to three questions: who owns it, when does it become obsolete, and what stops it from updating expired state?
7. Treating Every Rebuild as a Performance Bug
Rebuilding widgets is normal Flutter behavior. The maintenance problem appears when unrelated responsibilities share a broad update boundary or expensive work happens during rendering.
Flutter recommends localizing setState calls to the subtree that actually needs updating and controlling the cost of build()methods.
For example, changing a cart badge should not require a whole dashboard to subscribe to every cart field.
Split widgets around meaningful responsibilities and use focused subscriptions where the chosen package supports them. Measure before adding layers of optimization.
const widgets can help with stable subtrees, but they cannot correct unclear state ownership.
8. Testing Only the Successful Path
A controller that passes its initial loading test may still fail during retries, account changes, or overlapping operations.
Tests should cover observable transitions:
- Loading succeeds with no results.
- Loading fails, then succeeds after retry.
- A newer request completes before an older one.
- A save fails without discarding the user’s draft.
- Signing out removes user-specific data.
Flutter recommends testing architectural components independently and together, using fakes to keep dependencies controllable.
A fake repository that returns responses in a chosen order can expose bugs that manual testing rarely reproduces.
9. Confusing In-Memory State With Persistence
An application-wide provider or store does not automatically survive process termination.
Teams should distinguish temporary UI restoration from durable business data. Flutter provides state-restoration mechanisms, while repositories can coordinate local and remote storage for offline-capable applications.
A checkout draft may require local persistence. An expanded panel may not. A server-confirmed order needs reconciliation with authoritative backend data.
Document what survives navigation, logout, and restart separately. Those events should not share an accidental persistence policy.
Make State Ownership Visible
Before introducing another controller or provider, define its consumers, authoritative data, lifetime, and allowed transitions.
Then make those decisions visible through narrow interfaces and targeted tests.
Maintainable Flutter state management allows a developer to answer a practical question quickly: when this value changes, which component owns the change, and which parts of the application should respond?


















