Skip to content
ExponaBLOG
FLUTTER · DAY 032 / 100

Choosing state management: practical tips and trade-offs

How I decide for each project.

Expona Admin8 min read

Opening

After shipping state management in a dozen Flutter apps—from a ride-hailing prototype to a high-transaction e-commerce platform—we've learned that choosing state management: practical tips and trade-offs is less about finding the objectively "best" solution and more about matching the tool to your team's constraints, the app's complexity, and your runway. There's no universal winner here. Provider, Riverpod, and Bloc each solve real problems, and picking the wrong one for the wrong reasons wastes months of refactoring.

This article walks through how we actually decide.

Team experience — The library your team knows well beats the 'best' one

We've seen teams adopt Bloc because it's "production-grade," only to ship late because half the team was learning state machines for the first time. We've also seen teams stick with Provider for three years on an app that would benefit from Riverpod, simply because onboarding new engineers was cheaper with the familiar tool.

The truth: your team's prior experience is the heaviest weight in the decision. If your backend team knows Redux from a web app, Bloc's event-driven architecture will feel natural. If you're hiring junior engineers who've only seen simple inherited widgets, Provider lowers the barrier to contribution. If your iOS team recently shipped with SwiftUI and Combine, Riverpod's reactive primitives will click faster.

This matters because state management code gets touched by multiple people over months. Cognitive overhead compounds. Code review becomes slower. Onboarding takes longer.

Our heuristic: If 70% of the core team has shipped with a tool before, default to that tool unless the app's complexity demands otherwise. The remaining 30% will catch up faster than the whole team learning something new.

App complexity — Simple apps: Provider. Data-heavy apps: Riverpod. Strict flows: Bloc

This is where the framework choice actually matters. Different tools excel at different scales.

Provider for straightforward apps

Use Provider when your app is primarily UI-driven: a food delivery menu, a note-taking app, a social feed. Provider shines because it's minimal. You inherit a ChangeNotifier, call notifyListeners(), and you're done. The learning curve is shallow, and the code is readable to anyone who knows OOP.

We shipped a ride-hailing driver app with Provider at the core. The state was straightforward: current ride, location updates, driver availability. We needed to notify widgets when those changed. Provider handled it with ~200 lines of state code across three classes.

dart
class DriverStateProvider extends ChangeNotifier {
  Ride? currentRide;
  LatLng? lastLocation;
  bool isAvailable = false;

  void acceptRide(Ride ride) {
    currentRide = ride;
    notifyListeners();
  }

  void updateLocation(LatLng location) {
    lastLocation = location;
    notifyListeners();
  }

  void toggleAvailability(bool available) {
    isAvailable = available;
    notifyListeners();
  }
}

// In the widget tree:
ChangeNotifierProvider<DriverStateProvider>(
  create: (_) => DriverStateProvider(),
  child: MyApp(),
)

The downside: Provider doesn't enforce dependency injection or reactive data transformations. If you have derived state (e.g., "rides in the last 24 hours"), you manually track it. If you have async dependencies, you need to wire up FutureBuilder or StreamBuilder yourself.

Riverpod for data-heavy apps

Use Riverpod when your app handles complex, derived, or time-dependent state. Riverpod is Provider's spiritual successor, built to handle reactivity elegantly. It gives you computed properties, automatic caching, side effect management, and dependency injection out of the box.

We rebuilt a CRM dashboard with Riverpod because the data model was intricate: filter contacts by region, then by activity level, then compute engagement scores. With Provider, we'd have written boilerplate watchers. With Riverpod, the dependencies are declarative.

dart
// Fetch contacts
final contactsProvider = FutureProvider<List<Contact>>((ref) async {
  return await contactService.fetchContacts();
});

// Filter by region
final selectedRegionProvider = StateProvider<String>((ref) => 'US');

final filteredContactsProvider = FutureProvider<List<Contact>>((ref) async {
  final contacts = await ref.watch(contactsProvider.future);
  final region = ref.watch(selectedRegionProvider);
  return contacts.where((c) => c.region == region).toList();
});

// Compute engagement score (derived state)
final engagementScoreProvider = Provider<Map<String, double>>((ref) {
  final contacts = ref.watch(filteredContactsProvider).maybeWhen(
    data: (contacts) => contacts,
    orElse: () => [],
  );
  return {
    for (final c in contacts)
      c.id: _computeScore(c),
  };
});

Riverpod handles invalidation and re-computation automatically. When the region changes, the filtered list recomputes, which triggers the engagement score to recompute. No manual watchers, no race conditions.

Bloc for strict, testable flows

Use Bloc when your app has explicit user flows that need to be tested as state machines. Bloc forces you to think about events and state transitions explicitly. It's heavier upfront but pays off when you have complex user journeys or regulatory requirements.

We used Bloc for a trading app where order submission had strict state transitions: idle → validating → submitting → confirmed or rejected. Each transition had side effects (API calls, logging, analytics). Testing these flows required verifying that invalid transitions were rejected and that side effects fired in the correct order.

dart
abstract class OrderEvent extends Equatable {}

class SubmitOrderEvent extends OrderEvent {
  final Order order;
  SubmitOrderEvent(this.order);
  @override
  List<Object> get props => [order];
}

abstract class OrderState extends Equatable {}

class OrderIdleState extends OrderState {
  @override
  List<Object> get props => [];
}

class OrderSubmittingState extends OrderState {
  @override
  List<Object> get props => [];
}

class OrderConfirmedState extends OrderState {
  final String confirmationId;
  OrderConfirmedState(this.confirmationId);
  @override
  List<Object> get props => [confirmationId];
}

class OrderBloc extends Bloc<OrderEvent, OrderState> {
  final OrderRepository orderRepository;

  OrderBloc(this.orderRepository) : super(OrderIdleState()) {
    on<SubmitOrderEvent>((event, emit) async {
      emit(OrderSubmittingState());
      try {
        final confirmation = await orderRepository.submitOrder(event.order);
        emit(OrderConfirmedState(confirmation.id));
      } catch (e) {
        emit(OrderIdleState()); // Back to idle on error
      }
    });
  }
}

Testing this is straightforward: you emit events and assert the state sequence. No mocking widgets, no timing issues. Each transition is explicit and verifiable.

Testability — All three test well when logic lives outside widgets

This is the secret many teams miss: your choice of state management library matters less for testability than whether you separate logic from UI.

If your business logic lives in a widget's build method, no state management tool will save you. If it lives in a separate class, all three (Provider, Riverpod, Bloc) are testable.

The pattern that works: put your state and business logic in a class. Test that class directly. Then wire it into your widgets via the state management tool.

dart
// Business logic (testable)
class ContactFilter {
  List<Contact> filterByRegion(List<Contact> contacts, String region) {
    return contacts.where((c) => c.region == region).toList();
  }
}

// Test it directly
void main() {
  test('filters contacts by region', () {
    final filter = ContactFilter();
    final contacts = [
      Contact(id: '1', region: 'US'),
      Contact(id: '2', region: 'EU'),
    ];
    final result = filter.filterByRegion(contacts, 'US');
    expect(result.length, 1);
    expect(result.first.id, '1');
  });
}

This logic is independent of Provider, Riverpod, or Bloc. You can test it with plain Dart. Then, in your state management layer, you wire it in and test the integration separately.

Provider, Riverpod, and Bloc all support this pattern. The difference is ergonomics. Riverpod makes dependency injection easier with providers. Bloc enforces event/state separation, making flows clearer. Provider is simpler but requires more manual wiring.

Consistency — One approach across the app matters most

We've seen apps with Provider in some features, Bloc in others, and raw setState in a third. This creates cognitive load. Engineers context-switch. Code review becomes slower. Maintenance becomes a nightmare.

Pick one pattern and enforce it across the codebase.

This doesn't mean you can't have exceptions. A lightweight feature might use Provider while the complex payment flow uses Bloc. But the exceptions should be rare and documented. The default should be consistent.

Use linting to enforce this. Add a lint rule that flags setState usage if you've decided on Riverpod. Require all top-level state to inherit from a base class or use a provider factory pattern. Make consistency a code review norm.

We've found that teams shipping faster tend to have one clear decision: "This is a Riverpod app" or "This is a Bloc app." New engineers onboard faster. Code reviews are quicker. Refactoring is less risky because the patterns are predictable.

Common mistakes

Overcomplicating for "scalability." A startup's MVP doesn't need Bloc. Use Provider, ship, and refactor to Riverpod when the data model actually becomes complex. Premature optimization costs time.

Ignoring team velocity in the decision. The "best" tool for a three-person team is different from the best tool for a thirty-person team. Thirty people benefit from Bloc's strictness and enforced patterns. Three people benefit from Provider's simplicity.

Mixing patterns for aesthetic reasons. Some engineers prefer Bloc because it "looks more professional." But if your team knows Provider and the app is simple, adding Bloc adds friction for no gain. Ego-driven architecture decisions slow teams down.

Not separating business logic from state management. If your state class contains network calls, UI logic, and state mutations, no tool will help. Separate concerns first, then choose the tool.

Key takeaways

  • Default to the tool your team has shipped with before. Familiarity compounds across a project.
  • Match complexity to the tool: Provider for simple apps, Riverpod for data-heavy ones, Bloc for strict flows.
  • Testability comes from architecture (separating logic from UI), not from the state management library.
  • Consistency within a codebase matters more than picking the theoretically "best" tool.
  • Avoid premature complexity. Start simple, refactor when constraints appear.
  • Document your choice and enforce it across code reviews and lint rules.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles