Skip to content
ExponaBLOG
FLUTTER · DAY 024 / 100

The state management landscape: best practices explained

Many options. Pick based on your team.

Expona Admin9 min read

The state management landscape: best practices explained. Many options. Pick based on your team.

Introduction

By day 24 of building Flutter applications, you've likely felt the friction of managing state across multiple screens and widgets. The state management landscape has expanded dramatically over the past few years, with each solution offering different tradeoffs between simplicity, scalability, and developer experience. Understanding these options and picking the right one for your team's workflow can mean the difference between a codebase that scales smoothly and one that becomes increasingly difficult to test and maintain.

In this guide, we'll walk through the most popular state management approaches, examine their real-world tradeoffs, and help you make an informed decision based on your team's specific needs and constraints.

setState: Local, simple, and limited

setState is the built-in state management mechanism in Flutter. When you call setState, Flutter rebuilds the widget and its children, allowing you to update local UI state. It's the fastest way to get something working and requires no external packages.

When to use it: Single-screen forms, UI toggles, loading indicators, and any state that belongs entirely within one widget tree.

Real-world scenario: A settings screen where the user toggles notifications. The toggle state lives only on that screen—no other part of the app needs to know about it.

dart
class NotificationToggle extends StatefulWidget {
  @override
  State<NotificationToggle> createState() => _NotificationToggleState();
}

class _NotificationToggleState extends State<NotificationToggle> {
  bool _notificationsEnabled = false;

  @override
  Widget build(BuildContext context) {
    return Switch(
      value: _notificationsEnabled,
      onChanged: (value) {
        setState(() {
          _notificationsEnabled = value;
        });
      },
    );
  }
}

Limitations: As your app grows, setState becomes problematic. You can't easily share state across distant parts of your widget tree, testing is harder, and rebuilding becomes inefficient. You'll find yourself passing callbacks up and down the tree.

Provider: Simple dependency injection with ChangeNotifier

Provider is a wrapper around InheritedWidget that makes dependency injection and state sharing straightforward. You define ChangeNotifier classes that notify listeners when state changes, then wrap your app with providers.

When to use it: Apps with moderate state complexity, teams familiar with OOP patterns, or when you need gradual migration from setState.

Real-world scenario: An e-commerce app where you need to share a shopping cart across multiple screens—the product detail page, checkout flow, and order confirmation all read from the same cart state.

dart
class CartNotifier extends ChangeNotifier {
  List<CartItem> _items = [];

  List<CartItem> get items => _items;

  void addItem(CartItem item) {
    _items.add(item);
    notifyListeners();
  }

  void removeItem(String itemId) {
    _items.removeWhere((item) => item.id == itemId);
    notifyListeners();
  }

  double get total => _items.fold(0, (sum, item) => sum + item.price);
}

// In your main widget tree:
ChangeNotifierProvider(
  create: (_) => CartNotifier(),
  child: MyApp(),
)

Provider is popular because it lowers the barrier to entry—you write code that feels familiar, and the library handles the wiring. However, it relies on runtime reflection for some features, and the lack of type safety in certain scenarios can lead to hard-to-debug issues.

Riverpod: Compile-safe, testable, and modern

Riverpod is a complete rethink of reactive state management in Flutter. It provides compile-time safety, automatic dependency tracking, and powerful caching out of the box. Unlike Provider, Riverpod doesn't rely on BuildContext to access providers.

When to use it: New projects where you want strong type safety and excellent testing, or teams that value functional programming patterns.

Real-world scenario: A trading dashboard that fetches live price data, caches it, and refreshes on demand. Riverpod's built-in caching and invalidation make this pattern natural.

dart
// Define a provider for fetching prices
final pricesProvider = FutureProvider.autoDispose((ref) async {
  final apiClient = ref.watch(apiClientProvider);
  return apiClient.fetchPrices();
});

// In your widget, watch the provider
class PriceDisplay extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final pricesAsync = ref.watch(pricesProvider);

    return pricesAsync.when(
      data: (prices) => Text('BTC: \$${prices.btc}'),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('Error: $err'),
    );
  }
}

Riverpod's main advantage is testability—you can override providers in tests without building the widget tree. The learning curve is steeper, especially the concept of providers as functions rather than classes, but teams report fewer runtime surprises.

Bloc/Cubit: Explicit events and states

Bloc (Business Logic Component) separates business logic from UI by modeling state as a stream of immutable states. You emit events to a Bloc, the Bloc processes them, and emits new states. Cubit is a lighter variant that skips the explicit event layer.

When to use it: Complex applications with well-defined workflows, teams that prefer functional reactive programming, or projects requiring heavy offline support.

Real-world scenario: A ride-hailing app where booking a ride involves multiple steps: searching for drivers, handling no-driver scenarios, tracking acceptance, and starting the ride. Each state is discrete and testable.

dart
// Define states
abstract class BookingState {}
class BookingInitial extends BookingState {}
class BookingSearching extends BookingState {}
class BookingFound extends BookingState {
  final Driver driver;
  BookingFound(this.driver);
}
class BookingRejected extends BookingState {}

// Define events
abstract class BookingEvent {}
class SearchDriver extends BookingEvent {
  final Location pickup, dropoff;
  SearchDriver(this.pickup, this.dropoff);
}

// Define the Bloc
class BookingBloc extends Bloc<BookingEvent, BookingState> {
  BookingBloc() : super(BookingInitial()) {
    on<SearchDriver>((event, emit) async {
      emit(BookingSearching());
      try {
        final driver = await _service.findDriver(
          event.pickup,
          event.dropoff,
        );
        emit(BookingFound(driver));
      } catch (e) {
        emit(BookingRejected());
      }
    });
  }
}

Bloc excels at modeling complex user flows. The explicit event-state model makes it easy to reason about what happened and when. The downside: more boilerplate. For simple apps, it can feel over-engineered.

There's no single winner

We've seen large, successful apps built with setState alone, apps with thousands of users running on Provider, and enterprise products built on Riverpod or Bloc. The determining factor isn't the library—it's consistency and discipline.

A team using Provider consistently will outperform a team switching between setState, Provider, and Riverpod in different screens. When everyone understands the patterns and follows them, the codebase remains navigable and testable.

The real cost of state management is cognitive overhead, not lines of code. Pick a solution your team understands deeply, document it in your wiki, and enforce it in code review. Switching tools mid-project is expensive.

That said, context matters. A small MVP built for a specific feature might reasonably use Provider everywhere, while the next project—with different constraints—might benefit from Riverpod. The key is making that choice once, documenting the reasoning, and sticking with it.

Separate concerns: Server data, app state, and UI state

One mistake we see repeatedly is treating all state the same. In reality, you're managing three distinct types:

  1. Server data: Information fetched from your backend—user profile, product catalog, messages. This data is shared, remote, and can become stale.
  2. App state: The current context of the user's session—are they logged in, which team are they viewing, did they accept notifications. This state is persistent and scoped to the user.
  3. UI state: Transient, local information—is the dropdown open, what's the value in the search field, is the sheet expanded. This state doesn't survive app restarts.

Each type has different needs for caching, invalidation, and persistence.

Server data typically belongs in a dedicated layer (Dio + interceptors, or packages like riverpod_infinite_scroll). You want caching with time-based or event-based invalidation. If the user pulls to refresh, you invalidate. If 5 minutes pass, you might auto-refresh. If the user modifies an item, you invalidate related queries.

App state should persist across app restarts (use hive, shared_preferences, or a local database). Your state management tool should integrate with a persistence layer. In Riverpod, this might be a custom provider that hydrates from disk. In Bloc, it might be a HydrationMixin.

UI state is ephemeral and local. setState or a simple ValueNotifier often suffice. Don't overthink it.

Real-world example: A CRM app with contacts. The contact list (server data) is cached and invalidated when you add a contact. The selected contact ID (app state) persists so reopening the app shows the same contact. The search field text (UI state) is forgotten when you navigate away.

dart
// Server data: Riverpod with caching
final contactsProvider = FutureProvider.autoDispose((ref) async {
  final api = ref.watch(apiProvider);
  return api.listContacts();
});

// App state: Persistent via Riverpod with hydration
final selectedContactIdProvider = 
    StateNotifierProvider<SelectedContactNotifier, String?>((ref) {
  return SelectedContactNotifier();
});

// UI state: Local to the widget
class SearchField extends StatefulWidget {
  @override
  State<SearchField> createState() => _SearchFieldState();
}

class _SearchFieldState extends State<SearchField> {
  late TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
}

Common mistakes

Mistake 1: Mixing concerns. Storing UI state in your Bloc or Provider. Example: putting the search field text in your Bloc when it should live in the widget. This makes your state management tool harder to reason about and slows down rebuilds.

Mistake 2: Over-caching server data. Caching responses indefinitely without invalidation strategy. Users see stale data and report bugs. Always define when cached data expires.

Mistake 3: Choosing based on examples, not requirements. "Bloc is used by Google" is not a reason to use Bloc. Choose based on your team's experience, your app's complexity, and your testing needs.

Mistake 4: Switching mid-project. Starting with setState, realizing it's limiting, then refactoring to Provider is expensive and error-prone. Make the choice upfront, even if it feels conservative.

Mistake 5: Forgetting about offline state. When your app goes offline, cached server data can't be refreshed. Think about how your state management tool handles this scenario. Bloc with HydrationMixin handles this well. So does Riverpod with local persistence.

Key takeaways

  • setState works fine for truly local state—toggles, form fields, sheet expansion. Don't over-engineer it.
  • Provider is the gentle slope—if you're coming from StatefulWidget, it's the smallest step up while still enabling app-wide state sharing.
  • Riverpod prioritizes compile-time safety and testability—worth adopting if you're building a new project and your team can invest in learning it.
  • Bloc excels at modeling complex workflows—use it when your app has distinct states and transitions that need to be tested and documented.
  • Consistency beats perfection—pick one approach and use it everywhere. Document it. Train newcomers on it.
  • Separate concerns by type of state—server data, app state, and UI state have different lifecycles and caching needs. Handle each appropriately.
  • Test your choice with a realistic feature—before committing to a tool, implement a medium-complexity feature (user login, CRUD form, multi-step workflow) to feel the friction.
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