Don't Mix Three State Libraries
I've walked into enough Flutter codebases to spot the pattern immediately: Provider in one screen, BLoC in another, and plain setState scattered throughout. The moment a new developer opens the codebase, they hit friction. Don't mix three state libraries—or even two, if you can avoid it. Consistency in how state flows through your app is one of the highest-impact decisions you'll make early, and it pays dividends every time you onboard someone new or refactor a feature.
Over my five-plus years building production apps, I've learned that the pain of choosing wrong is vastly outweighed by the pain of choosing many. This article covers how to settle on one primary solution, document your conventions, know when setState actually fits, and how to gradually clean up legacy code without burning out your team.
Pick One Primary Solution
The first rule is simple: have a decision. Whether you choose Provider, BLoC, GetX, Riverpod, or even plain setState with careful architectural boundaries, commit to it. The worst state I've seen was a app that used Riverpod for network calls, Provider for UI state, GetX for navigation, and setState for everything nobody wanted to refactor. Each developer had a different mental model, code reviews turned into debates about pattern correctness, and onboarding took months instead of weeks.
My recommendation depends on your team size and project maturity:
- Provider: Best for small to medium teams building iteratively. Simple, predictable, low cognitive overhead. I reach for this first in most projects because it doesn't require heavy architecture upfront.
- Riverpod: Better than Provider once you've outgrown it. Fully reactive, compile-time safe, excellent for large teams. Add this only if you're already comfortable with Provider.
- BLoC: Strongest for large teams with strict separation requirements. Steeper learning curve. Use if your team already knows event-driven architecture well.
- GetX: Fast to ship, but creates tight coupling over time. I avoid it in production because the convenience becomes technical debt.
Whatever you pick, communicate it clearly. Post a decision document in your project wiki. Link to it during code review. Make it a conversation topic in your first team sync, not a surprise people discover halfway through Q2.
Document the Conventions
Choosing the library is 20% of the work. The other 80% is establishing how it's used consistently. I've seen two teams use Provider in completely different ways—one with nested ChangeNotifier classes, another with single-responsibility service classes—and neither approach was wrong, but mixing them created confusion.
Your documentation should cover:
- File structure and naming (where does the state live, how is it named)
- Responsibility boundaries (what logic goes in the state class vs. the widget vs. a service layer)
- How to handle side effects (network calls, database writes, analytics)
- Testing strategy (mock patterns, fake data)
- When to use lower-level solutions (when is setState acceptable in this provider-based app)
Here's a minimal example for a Provider-based app:
// Bad: vague responsibility
class UserProvider extends ChangeNotifier {
User? user;
String? errorMessage;
bool isLoading = false;
void fetchUser(String id) async {
isLoading = true;
notifyListeners();
try {
final response = await http.get(Uri.parse('/users/$id'));
user = User.fromJson(jsonDecode(response.body));
errorMessage = null;
} catch (e) {
errorMessage = e.toString();
user = null;
}
isLoading = false;
notifyListeners();
}
}
// Good: clear responsibility, service-driven
class UserRepository {
final HttpClient client;
UserRepository(this.client);
Future<User> fetchUser(String id) async {
final response = await client.get(Uri.parse('/users/$id'));
return User.fromJson(jsonDecode(response.body));
}
}
class UserProvider extends ChangeNotifier {
final UserRepository repository;
User? user;
String? errorMessage;
bool isLoading = false;
UserProvider(this.repository);
Future<void> loadUser(String id) async {
isLoading = true;
notifyListeners();
try {
user = await repository.fetchUser(id);
errorMessage = null;
} catch (e) {
errorMessage = e.toString();
user = null;
}
isLoading = false;
notifyListeners();
}
}Document this. Add a code example to your README. Link to it in your PR template. In onboarding, review one screen together and explain the pattern. That fifteen-minute conversation prevents fifty hours of back-and-forth later.
setState for Local UI State Is Fine
Here's the permission slip you didn't know you needed: setState is not a sin. A lot of blogs make it sound like using setState means you've failed as an architect. I don't subscribe to that. If your app is 90% Provider-based, using setState for a toggle in a modal or a text field focus state is pragmatic, not lazy.
setState is appropriate when:
- The state is genuinely local (only that widget cares about it)
- The state has no side effects (no API calls, no database writes)
- The state doesn't need to be tested separately
- Rebuilding the entire subtree is acceptable for performance
setState is not appropriate when:
- The state is read by sibling or ancestor widgets
- You're managing a list, form, or multi-step flow
- The state handles errors, loading, or async operations
- You need to test the logic in isolation
Example: a search screen with a text field and filter dropdown.
// This is fine as setState—truly local UI state
class SearchScreen extends StatefulWidget {
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
String _searchQuery = '';
String _selectedCategory = 'all';
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: (value) {
setState(() => _searchQuery = value);
},
),
DropdownButton(
value: _selectedCategory,
onChanged: (value) {
setState(() => _selectedCategory = value ?? 'all');
},
items: [...],
),
// Now hand off to a Provider-based widget that handles the real work
SearchResultsWidget(query: _searchQuery, category: _selectedCategory),
],
);
}
}The results are fetched by SearchResultsWidget, which uses your primary state library. The input management stays local. Everyone wins.
Refactor Old Screens Gradually
If you're joining a codebase with mixed patterns, don't boil the ocean. The team doesn't have the bandwidth to rewrite every screen, and you'll burn goodwill fast. Instead, establish the new pattern and refactor on a natural schedule.
I approach this in two phases:
Phase 1: Freeze the old pattern. No new screens in setState or the old library. Code review catches it. Any new feature defaults to your chosen primary library. This stops the bleeding immediately.
Phase 2: Refactor on maintenance windows. When someone fixes a bug in an old screen, spend an extra hour migrating it to the new pattern. When a feature is being extended, bundle the refactor with the feature work. After six months, most of the app has migrated naturally.
Here's a realistic example. You're migrating from GetX to Provider:
// Old GetX-based controller
class ProductController extends GetxController {
var products = <Product>[].obs;
var isLoading = false.obs;
void loadProducts() async {
isLoading.value = true;
products.value = await productService.fetch();
isLoading.value = false;
}
}
class ProductScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final controller = Get.put(ProductController());
return Obx(
() => controller.isLoading.value
? CircularProgressIndicator()
: ListView(children: [
for (final p in controller.products)
ProductTile(product: p),
]),
);
}
}
// New Provider-based equivalent
class ProductProvider extends ChangeNotifier {
final ProductService service;
List<Product> products = [];
bool isLoading = false;
ProductProvider(this.service);
Future<void> loadProducts() async {
isLoading = true;
notifyListeners();
products = await service.fetch();
isLoading = false;
notifyListeners();
}
}
class ProductScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<ProductProvider>(
builder: (context, provider, _) {
if (provider.isLoading) {
return CircularProgressIndicator();
}
return ListView(
children: [
for (final p in provider.products)
ProductTile(product: p),
],
);
},
);
}
}This refactor takes maybe thirty minutes. Slot it into your sprint when you're touching ProductScreen anyway. You reduce the complexity incrementally, and the team learns the pattern by doing, not by reading documentation.
Common Mistakes
Mistake 1: Choosing the "coolest" library instead of the "best fit." I've seen teams adopt Riverpod because it's newer and more powerful, only to spend three months fighting the reactive paradigm while their velocity tanked. Provider would have shipped in half the time. Match the tool to your team's current skill level and your project's actual requirements.
Mistake 2: Over-engineering local state. Not every boolean needs a notifier. A team member once asked me whether they should create a Provider for a modal visibility flag. I said no. They created one anyway to "stay consistent." Now the test suite is bloated, the codebase is harder to follow, and the consistency argument doesn't hold water because it's the exception, not the rule.
Mistake 3: Mixing libraries because you're afraid of committing. "Let's use Provider for data and BLoC for navigation—that way we're not locked in." You're locked in. Worse, you're locked into complexity. Commit. If you choose wrong, the cost of switching later is lower than the cost of maintaining two patterns now.
Mistake 4: Not documenting the boundaries. "Everyone knows to use Provider" is not a boundary. Spell it out. Make it reflex for new devs, not a question.
Key Takeaways
- Pick one primary state library and use it consistently. The cost of heterogeneity far exceeds the benefit of keeping options open.
- Document how the library is used: file structure, responsibility splits, side-effect handling, testing patterns. Link it in your README and PR template.
- setState is acceptable for truly local, side-effect-free UI state. Know when it's the pragmatic choice and when it's a smell.
- Refactor old patterns gradually on maintenance windows, not in a big rewrite. Phase in the new approach, freeze the old one, and let natural maintenance cycles handle the migration.
- Consistency compounds: every screen that follows the pattern makes the next one faster to write and review. Inconsistency compounds too—in the wrong direction.






Comments
Loading comments…