Steven's Knowledge

State Management

Flutter state management — selecting and using Provider, Riverpod, Bloc, GetX with architectural patterns

State Management

Flutter has no officially blessed state management solution; the community has produced many. This page compares the mainstream options and gives selection guidance.

State Categories

Not every state needs "managing". Categorize by scope first, then pick the simplest tool:

State TypeExampleRecommended Tool
EphemeralText input value, selected tabStatefulWidget + setState
Widget-localForm controllers, animationsStatefulWidget
Cross-Widget sharedTheme, current userInheritedWidget / Provider
App-level business stateCart, order listRiverpod / Bloc
Persisted stateLogin, settingsRepository + persistence + state layer

Anti-pattern: shoving every state into a global store. If setState solves it, you do not need a state library. State libraries exist to share state across Widgets and to decouple business logic — not to replace setState.

Comparing the Options

SolutionLearning CurveCompile-time SafetyTest-friendlyCommunityRecommended For
setState-Medium-Ephemeral state, demos
Provider⭐⭐Low (runtime lookup)HighMaintainedSmall/medium projects, migrations
Riverpod⭐⭐⭐High (compile-time)HighActiveFirst choice for new projects
Bloc⭐⭐⭐⭐HighHighActiveLarger teams, strict architecture
GetX⭐⭐LowLowControversialDiscussed below
MobX⭐⭐⭐MediumMediumStableIf you come from React/MobX
signals⭐⭐HighHighEmergingFans of reactive signals

Selection Decision Tree

New project?
├── Yes → Riverpod (compile-time safety + DevTools integration)
└── No  → Stick with what the project already uses; migration cost is high
        └── Team ≥ 5 + strict architectural needs? → Consider Bloc

Provider

The original officially-recommended solution; wraps InheritedWidget.

// 1. Define the model
class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

// 2. Inject
ChangeNotifierProvider(
  create: (_) => CounterModel(),
  child: MyApp(),
)

// 3. Consume
context.watch<CounterModel>().count            // subscribe in build
context.read<CounterModel>().increment()       // read in event callbacks
context.select<CounterModel, int>((m) => m.count)  // granular subscription

Pros: simple API, official endorsement, plentiful docs.

Cons:

  • Provider.of<T> errors only surface at runtime
  • Nested MultiProvider becomes hard to read
  • Complex dependency graphs are painful to express

Riverpod

The Provider author's rewrite. While ProviderScope still uses an InheritedWidget under the hood for lookup, the public API decouples providers from BuildContext and gains compile-time safety. The recommended modern API is Notifier / AsyncNotifier (with riverpod_generator); StateNotifierProvider is now soft-deprecated and kept only for migration.

// 1. Define the provider — modern Notifier API (Riverpod 2.x)
final counterProvider = NotifierProvider<Counter, int>(Counter.new);

class Counter extends Notifier<int> {
  @override
  int build() => 0;            // initial state

  void increment() => state++;
}

// 2. Use under a ProviderScope
runApp(ProviderScope(child: MyApp()));

// 3. Subscribe in a ConsumerWidget
class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Column(children: [
      Text('$count'),
      ElevatedButton(
        onPressed: () => ref.read(counterProvider.notifier).increment(),
        child: const Text('+1'),
      ),
    ]);
  }
}

Powerful Features

Auto-derivation:

final userProvider = FutureProvider<User>((ref) => api.fetchUser());

final userNameProvider = Provider<String>((ref) {
  return ref.watch(userProvider).maybeWhen(
    data: (u) => u.name,
    orElse: () => 'Loading',
  );
});

Family providers (parameterized; family is also available on NotifierProvider / AsyncNotifierProvider):

final orderProvider = FutureProvider.family<Order, String>(
  (ref, id) => api.fetchOrder(id),
);

ref.watch(orderProvider('order-123'));

autoDispose: clean up automatically when nobody is subscribed:

final searchProvider = FutureProvider.autoDispose.family<List<Item>, String>(
  (ref, query) => api.search(query),
);

ref.listen for side effects on change:

ref.listen<int>(counterProvider, (prev, next) {
  if (next > 10) showSnackBar('too many!');
});

Riverpod Generator

riverpod_generator cuts boilerplate via annotations:

@riverpod
Future<User> user(UserRef ref, String id) async {
  return await ref.read(apiProvider).fetchUser(id);
}
// userProvider is generated

Bloc

An event-stream-based architecture: opinionated and consistent. Suited to large projects.

import 'package:equatable/equatable.dart';

// 1. Define events
sealed class CounterEvent {}
class Increment extends CounterEvent {}

// 2. Define state — MUST implement value equality, otherwise Bloc dedupes
//    by identity and emit() won't trigger rebuilds for "same-shape" states
class CounterState extends Equatable {
  final int count;
  const CounterState(this.count);

  @override
  List<Object?> get props => [count];
}

// 3. Bloc
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState(0)) {
    on<Increment>((event, emit) => emit(CounterState(state.count + 1)));
  }
}

// 4. Inject
BlocProvider(create: (_) => CounterBloc(), child: MyApp())

// 5. Consume
BlocBuilder<CounterBloc, CounterState>(
  builder: (_, state) => Text('${state.count}'),
)

context.read<CounterBloc>().add(Increment())

Cubit (lightweight Bloc)

No events, just methods on the cubit:

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
}

Strengths

  • Enforces unidirectional data flow
  • Events and states are value types — easy to test
  • BlocObserver gives global observability for logging / debugging
  • Team conventions are uniform

Trade-offs

  • Lots of boilerplate (mitigated with freezed)
  • Even simple state needs an Event
  • Steeper learning curve

A Note on GetX

GetX bundles state, routing, DI, i18n, and utilities into a single package, which is initially attractive for small teams. The trade-offs to weigh:

  • Service-locator-style globals make tests harder (you must reset Get between cases) and obscure dependencies that would be explicit with Provider/Riverpod.
  • Reactive primitives (.obs) mutate values in place; type inference and refactoring tools cannot follow them as cleanly as Notifier/StateNotifier.
  • Mixed concerns: putting routing, DI, and state in one package conflicts with the "small composable libraries" direction the rest of the ecosystem has taken.

Use it if your team is already productive with it; for a new project today, Riverpod or Bloc is a safer long-term bet.

Architectural Patterns

MVVM with Provider / Riverpod

View (Widget)
  ↓ ref.watch
ViewModel (StateNotifier / ChangeNotifier)
  ↓ calls
UseCase / Service
  ↓ calls
Repository

Data Source (Remote / Local)

Layer responsibilities:

LayerOwnsShould NOT
ViewRendering, capturing user eventsBusiness logic, HTTP calls
ViewModelPage state, UseCase invocationUI details, knowing about Widgets
UseCaseA single business use case ("place order", "refund")Knowing about UI
RepositoryAbstracts data sourcesBusiness rules
Data SourceProtocol details (Dio, SharedPreferences)Business, caching policy

Bloc + Clean Architecture

Presentation (Bloc + Widget)

Domain (Entity + UseCase + Repository interface)

Data (Repository impl + Model + Data Source)

The Domain layer has no Flutter dependency — pure Dart. Easy to share across platforms and extremely testable.

Data Fetching and Caching

class OrderRepository {
  final OrderApi _api;
  final OrderCache _cache;

  Future<Order> get(String id) async {
    final cached = await _cache.get(id);
    if (cached != null && !cached.isStale) return cached;
    final fresh = await _api.fetch(id);
    await _cache.put(fresh);
    return fresh;
  }
}

Server State vs Client State

Borrowing from React Query's framing:

  • Server state: comes from an API; has staleness; needs retries / caching / refetching
  • Client state: pure UI (modal open/closed, tab selected); unrelated to the server

Riverpod's FutureProvider + autoDispose + family covers most server-state needs. Flutter equivalents to React Query include fl_query and Riverpod-based caching solutions.

Persistence and Restoration

Simple Preferences

shared_preferences — key-value, lightweight.

Structured Data

hive / isar / drift — local databases.

State Restoration

Restoring to the original page after the OS kills the app:

MaterialApp(
  restorationScopeId: 'root',  // enable
  ...
)

class _MyState extends State<MyWidget> with RestorationMixin {
  final _count = RestorableInt(0);

  @override
  String get restorationId => 'counter';

  @override
  void restoreState(RestorationBucket? old, bool initial) {
    registerForRestoration(_count, 'count');
  }
}

Testing

Provider / Riverpod Tests

Riverpod ships ProviderContainer so tests need no Widget at all:

test('counter increments', () {
  final container = ProviderContainer();
  expect(container.read(counterProvider), 0);
  container.read(counterProvider.notifier).increment();
  expect(container.read(counterProvider), 1);
});

Bloc Tests

The bloc_test package provides blocTest():

blocTest<CounterBloc, CounterState>(
  'emits [1] when Increment is added',
  build: () => CounterBloc(),
  act: (bloc) => bloc.add(Increment()),
  expect: () => [CounterState(1)],
);

Anti-Patterns

1. Global mutable singletons

// Anti-pattern
class GlobalState {
  static int counter = 0;
}

Untestable, unobservable, no mock injection. Use a state library instead.

2. Calling APIs directly in Widgets

Putting API calls in onPressed makes mocking, retrying, and caching impossible. Always go through a Repository layer.

3. ChangeNotifier holding BuildContext

ChangeNotifier should be pure logic — it must not know about Widgets. Trigger navigation / dialogs via callbacks or Streams handed to the UI layer.

4. State scattered across multiple sources

If the same state lives in both local setState and a Provider, drift is inevitable. Apply the single source of truth principle.

Rules of thumb

  • Single page, ephemeral state → setState
  • Simple sharing across 2–3 widgets → InheritedWidget / Provider
  • App-level business state → Riverpod
  • Large team + strict architecture → Bloc
  • Do not adopt Bloc + Clean Architecture in a small project to "look professional" — you will drown in boilerplate

On this page