State Management
What State Management Is
Section titled “What State Management Is”Flutter renders UI by calling build() on widgets. The build() method returns a widget tree based On the current state. When state changes, build() must be called again to produce an updated tree. The problem is: where does the state live, how does it change, and how does the framework know to Rebuild?
Without a deliberate strategy, state ends up scattered across widget fields, callbacks, and global Variables. This works for a prototype but collapses under the weight of a real application: Propagating a change from a deep widget to an ancestor requires passing callbacks through every Intermediate widget (callback hell), shared state becomes uncontrollable without a single source of Truth, and rebuilds become unpredictable because the framework cannot determine which widgets depend On which state.
State management is the discipline of controlling where state is stored, how it is modified, And which widgets rebuild when it changes. Every solution in this document addresses the same Fundamental problem — they differ in complexity, boilerplate, testability, and scalability.
Why setState Alone Does Not Scale
Section titled “Why setState Alone Does Not Scale”setState marks a single State object as dirty, triggering a rebuild of that widget and its Descendants. This works for local UI state (a toggle, a text field, an animation progress) but fails For shared application state (user session, shopping cart, feature flags):
class CounterPage extends StatefulWidget { @override State<CounterPage> createState() => _CounterPageState();}
class _CounterPageState extends State<CounterPage> { int _count = 0;
void _increment() { setState(() { _count++; // This rebuilds CounterPage and ALL its descendants }); }
@override Widget build(BuildContext context) { return Column( children: [ Text("Count: $_count'), ExpensiveWidget(), // Rebuilds even though it doesn't use _count AnotherExpensiveWidget(), // Also rebuilds ], ); }}The problems:
- Excessive rebuilds —
setStateat the top rebuilds the entire subtree, including widgets that do not depend on the changed state. - No sharing. State in a
Stateobject is inaccessible to sibling or ancestor widgets without callbacks. - No persistence. When the widget is disposed, the state is lost.
- No separation of concerns. Business logic lives in the widget, making it untestable and unreusable.
setState and InheritedWidget
Section titled “setState and InheritedWidget”How setState Works
Section titled “How setState Works”When you call setState()Flutter marks the State object’s element as dirty. On the next frame, The framework calls build() on that element and reconciles the new widget tree with the old one. Only widgets whose configuration changes are updated in the render tree.
setState(() { _count++;});The callback is synchronous. It runs immediately — not on the next frame. The rebuild is scheduled For the next frame. Multiple setState calls within the same microtask are batched into a single Rebuild.
Scope Limitations
Section titled “Scope Limitations”setState is scoped to a single State object. The state is private to that widget. To communicate State changes to a parent, you need a callback:
class TemperatureInput extends StatefulWidget { final void Function(double) onChanged; const TemperatureInput({required this.onChanged, super.key});
@override State<TemperatureInput> createState() => _TemperatureInputState();}
class _TemperatureInputState extends State<TemperatureInput> { double _value = 0.0;
@override Widget build(BuildContext context) { return Slider( value: _value, onChanged: (v) { setState(() => _value = v); widget.onChanged(v); // Propagate to parent }, ); }}This pattern does not scale. For three levels of nesting, you need three callback parameters. For Ten levels, the boilerplate becomes unmaintainable.
InheritedWidget
Section titled “InheritedWidget”InheritedWidget is Flutter’s built-in mechanism for propagating data down the widget tree. A Widget anywhere in the subtree can access the data via BuildContext without explicit parameter Passing.
class TemperatureData extends InheritedWidget { const TemperatureData({ required this.temperature, required this.onChanged, required super.child, super.key, });
final double temperature; final void Function(double) onChanged;
static TemperatureData of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<TemperatureData>()!; }
@override bool updateShouldNotify(TemperatureData oldWidget) { return temperature != oldWidget.temperature; }}
// Usage in a parent widgetclass TemperaturePage extends StatefulWidget { @override State<TemperaturePage> createState() => _TemperaturePageState();}
class _TemperaturePageState extends State<TemperaturePage> { double _temp = 20.0;
@override Widget build(BuildContext context) { return TemperatureData( temperature: _temp, onChanged: (v) => setState(() => _temp = v), child: Column( children: const [ TemperatureDisplay(), TemperatureSlider(), ], ), ); }}
// Any descendant can read the dataclass TemperatureDisplay extends StatelessWidget { const TemperatureDisplay({super.key});
@override Widget build(BuildContext context) { final data = TemperatureData.of(context); return Text('Temperature: ${data.temperature}°C'); }}
class TemperatureSlider extends StatelessWidget { const TemperatureSlider({super.key});
@override Widget build(BuildContext context) { final data = TemperatureData.of(context); return Slider( value: data.temperature, onChanged: data.onChanged, ); }}How updateShouldNotify Controls Rebuilds
Section titled “How updateShouldNotify Controls Rebuilds”updateShouldNotify determines whether descendants that depend on this InheritedWidget should be Rebuilt. If it returns falseNo descendant is notified. If it returns trueEvery widget that Called of() is rebuilt.
This is the granularity mechanism — you control which state changes trigger rebuilds by what you Compare in updateShouldNotify. Compare only the fields that descendants care about:
@overridebool updateShouldNotify(AppState oldWidget) { return user != oldWidget.user || theme != oldWidget.theme || locale != oldWidget.locale; // If only an internal counter changed, descendants won't rebuild}Limitations of InheritedWidget
Section titled “Limitations of InheritedWidget”- No built-in mutation API —
InheritedWidgetis immutable. To change its data, you must wrap it in aStatefulWidgetthat callssetStateThen provide a newInheritedWidgetwith updated data. This is the boilerplate thatProvidereliminates. - No notification granularity. When
updateShouldNotifyreturnstrueall dependents rebuild, not just the ones that care about the changed field. - No lifecycle management —
InheritedWidgetdoes not dispose resources. You must handle disposal in the wrappingStatefulWidget. - Verbose. Every piece of shared state requires a custom
InheritedWidgetsubclass withof()``updateShouldNotifyAnd a wrappingStatefulWidget.
Provider
Section titled “Provider”Provider is a thin wrapper around InheritedWidget that eliminates the boilerplate. It is the Most widely used state management solution in Flutter, recommended by the Flutter team for small to Medium applications.
dependencies: provider: ^6.1.0ChangeNotifier and ChangeNotifierProvider
Section titled “ChangeNotifier and ChangeNotifierProvider”ChangeNotifier is Dart’s built-in observable class from package:flutter/foundation.dart. It Maintains a list of listeners and notifies them when notifyListeners() is called:
import 'package:flutter/foundation.dart';
class Counter extends ChangeNotifier { int _count = 0;
int get count => _count;
void increment() { _count++; notifyListeners(); // Triggers rebuild of listening widgets }
void decrement() { _count--; notifyListeners(); }
void reset() { _count = 0; notifyListeners(); }}ChangeNotifierProvider creates the ChangeNotifierMakes it available to the subtree, and Automatically calls dispose() when the provider is removed from the tree:
void main() { runApp( ChangeNotifierProvider( create: (_) => Counter(), child: const MyApp(), ), );}
class CounterPage extends StatelessWidget { const CounterPage({super.key});
@override Widget build(BuildContext context) { // context.watch() subscribes to changes — rebuilds when notifyListeners() is called final counter = context.watch<Counter>();
return Column( children: [ Text('Count: ${counter.count}'), ElevatedButton( onPressed: counter.increment, child: const Text('Increment'), ), ], ); }}context.read() vs context.watch() vs context.select()
Section titled “context.read() vs context.watch() vs context.select()”context.watch<T>()— subscribes to the provider and rebuilds the widget whenevernotifyListeners()is called. Use it inbuild().context.read<T>()— returns the provider value without subscribing. Use it in event handlers and callbacks. Callingread()inbuild()is a mistake — it will not rebuild when state changes.context.select<T, R>(R Function(T) selector)— subscribes to a specific property of the provider. The widget rebuilds only when the selected value changes:
// Rebuilds only when counter.count changes, not on every notifyListeners()final count = context.select<Counter, int>((counter) => counter.count);Consumer and Selector
Section titled “Consumer and Selector”For fine-grained rebuild control within a widget tree, use Consumer and Selector:
// Consumer — rebuilds only its builder, not the entire parent widgetColumn( children: [ const HeaderWidget(), // Does not rebuild Consumer<Cart>( builder: (context, cart, child) { return Text('Items: ${cart.itemCount}'); }, ), const FooterWidget(), // Does not rebuild ],)
// Selector — rebuilds only when the selected value changesSelector<Cart, double>( selector: (_, cart) => cart.totalPrice, builder: (_, totalPrice, __) { return Text('Total: \$$totalPrice'); },)MultiProvider
Section titled “MultiProvider”When the app has multiple providers, use MultiProvider to nest them:
void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => AuthNotifier()), ChangeNotifierProvider(create: (_) => CartNotifier()), ChangeNotifierProvider(create: (_) => ThemeNotifier()), ], child: const MyApp(), ), );}Providers can depend on each other by reading other providers during creation:
MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => ApiClient()), ChangeNotifierProxyProvider<ApiClient, UserRepository>( create: (_) => UserRepository(), update: (_, api, repo) => repo!..updateApi(api), ), ],)Limitations of Provider
Section titled “Limitations of Provider”- No code generation. Every notifier is a hand-written class with manual
notifyListeners()calls. Forgetting to callnotifyListeners()causes silent bugs (state changes but UI does not update). - No async built-in. Handling loading/error states for async operations requires manual boilerplate.
- Mutable state —
ChangeNotifieris mutable by default. Any code with a reference can mutate the state directly, bypassing any validation logic. - No dependency injection for non-ChangeNotifier types —
Providerworks best withChangeNotifier. For plain objects, services, or repositories, you need separate provider types (Provider``FutureProvider``StreamProvider). - BuildContext dependency —
context.read()andcontext.watch()requireBuildContextwhich means state access is tied to the widget tree. You cannot access state outside of widgets (e.g., in a domain service or route guard).
Riverpod
Section titled “Riverpod”Riverpod was created by the same author as Provider (Remi Rousselet) to address its fundamental Limitations. It removes the dependency on BuildContextIntroduces immutable providers, supports Async out of the box, and provides code generation for less boilerplate.
dependencies: flutter_riverpod: ^2.5.0 riverpod_annotation: ^2.3.0
dev_dependencies: build_runner: ^2.4.0 riverpod_generator: ^2.4.0Providers as Immutable Declarative Values
Section titled “Providers as Immutable Declarative Values”In Riverpod, a provider is a declaration of how to create a value — not a mutable object. Providers Are immutable globals that you reference by name. The framework handles creation, disposal, and Dependency tracking:
import 'package:flutter_riverpod/flutter_riverpod.dart';
// A simple provider — creates an int valuefinal counterProvider = Provider<int>((ref) => 0);
// A state provider — mutable state without a full notifier classfinal counterStateProvider = StateProvider<int>((ref) => 0);
// A notifier provider — encapsulated state with business logicfinal counterNotifierProvider = NotifierProvider<CounterNotifier, int>( CounterNotifier.new,);
class CounterNotifier extends Notifier<int> { @override int build() => 0;
void increment() => state++; void decrement() => state--; void reset() => state = 0;}Provider Types
Section titled “Provider Types”// Provider — immutable, derived valuefinal greetingProvider = Provider<String>((ref) { final name = ref.watch(userNameProvider); return 'Hello, $name';});
// StateNotifierProvider — mutable state with a notifier (pre-code-gen)class TodosNotifier extends StateNotifier<List<Todo>> { TodosNotifier() : super([]);
void add(Todo todo) => state = [...state, todo]; void remove(String id) => state = state.where((t) => t.id != id).toList(); void toggle(String id) => state = [ for (final todo in state) if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo, ];}
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) { return TodosNotifier();});
// FutureProvider — async value with built-in loading/error statesfinal userProvider = FutureProvider<User>((ref) async { final api = ref.watch(apiClientProvider); return api.fetchCurrentUser();});
// StreamProvider — value from a stream with built-in statesfinal messagesProvider = StreamProvider<Message>((ref) { final channel = ref.watch(websocketProvider); return channel.messages;});
// AsyncNotifierProvider — state + async initialization (code-gen)@riverpodclass AsyncCounter extends _$AsyncCounter { @override Future<int> build() async { // Async initialization — e.g., load from storage final prefs = ref.read(sharedPreferencesProvider); return prefs.getInt('counter') ?? 0; }
void increment() { // state is AsyncValue<int> — use whenData state = const AsyncValue.data(0); state = AsyncData(state.value! + 1); }}ref.watch() vs ref.read()
Section titled “ref.watch() vs ref.read()”ref.watch(provider)— subscribes to the provider. When the provider’s value changes, the watching provider or widget rebuilds. Use insidebuild()and widgetbuild()methods.ref.read(provider)— reads the current value without subscribing. Use in event handlers, callbacks, and lifecycle methods. Usingref.read()insidebuild()is a mistake — the widget will not rebuild when the value changes.
final myWidgetProvider = Provider<void>((ref) { // CORRECT — watch in build to react to changes final user = ref.watch(userProvider);
// CORRECT — read in callbacks (no subscription) final onLogout = () { ref.read(authNotifierProvider.notifier).logout(); };
return null;});autoDispose
Section titled “autoDispose”Providers annotated with autoDispose are automatically disposed when no widget is watching them. This prevents memory leaks for providers that hold large resources (stream subscriptions, cached Data):
final searchResultsProvider = FutureProvider.autoDispose<List<Result>>((ref) async { final query = ref.watch(searchQueryProvider); if (query.isEmpty) return []; return api.search(query);});When the search UI is dismissed, the provider is disposed. When the search UI is shown again, the Provider is recreated. Use keepAlive() to override this for providers that should persist:
final cachedConfigProvider = FutureProvider.autoDispose<Config>((ref) async { final config = await fetchConfig(); ref.keepAlive(); // Prevent auto-disposal once loaded return config;});ref.invalidate()
Section titled “ref.invalidate()”ref.invalidate(provider) forces a provider to rebuild on next access. This is useful for Refreshing data:
final onRefresh = () { ref.invalidate(userProvider); // Forces re-fetch on next watch};All providers that depend on the invalidated provider are also invalidated — this cascading Invalidation ensures consistency.
Code Generation with @riverpod
Section titled “Code Generation with @riverpod”The @riverpod annotation eliminates boilerplate for common patterns:
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter.g.dart';
@riverpodint counter(CounterRef ref) => 0;
@riverpodclass CounterNotifier extends _$CounterNotifier { @override int build() => 0;
void increment() => state++; void decrement() => state--;}
@riverpodFuture<User> currentUser(CurrentUserRef ref) async { final api = ref.watch(apiClientProvider); return api.fetchCurrentUser();}Run the generator:
dart run build_runner build --delete-conflicting-outputsThis generates the provider definitions in counter.g.dart. The generated code handles the provider Family, autoDisposeAnd proper typing.
Why Riverpod Is Recommended for New Projects
Section titled “Why Riverpod Is Recommended for New Projects”- No BuildContext dependency. Providers are global constants. You can access state from anywhere: widgets, domain services, route guards, middleware.
- Immutable by default. Providers declare how to create a value, not how to mutate it. State mutation goes through well-defined notifier APIs.
- Async-first —
FutureProviderandAsyncNotifierProviderhandle loading, error, and data states without manual boilerplate. - Auto-dispose. Prevents memory leaks by automatically disposing providers when they are no longer watched.
- Testable. Providers can be overridden in tests without widgets:
test('fetches user', () async { final container = ProviderContainer( overrides: [ apiClientProvider.overrideWithValue(mockApiClient), ], );
final user = await container.read(currentUserProvider.future); expect(user.name, equals('Alice'));});The BLoC (Business Logic Component) pattern enforces a strict separation between the UI and business Logic by using a unidirectional data flow: Events go in, States come out. The BLoC itself is a pure Function from event streams to state streams.
dependencies: flutter_bloc: ^8.1.0 equatable: ^2.0.5The Pattern: Events, BLoC, States
Section titled “The Pattern: Events, BLoC, States”UI ──(events)──> BLoC ──(states)──> UI- The UI dispatches an Event to the BLoC.
- The BLoC processes the event, updates its internal state, and emits a new State.
- The UI rebuilds in response to the new state.
This is a state machine. Every state transition is caused by a specific event. Every event produces A deterministic state transition (for synchronous events). This makes the system traceable — you can Log every event and state transition to reproduce bugs.
Defining Events and States
Section titled “Defining Events and States”import 'package:equatable/equatable.dart';
// Events — what happenedsealed class AuthEvent extends Equatable { @override List<Object?> get props => [];}
class AuthLoginRequested extends AuthEvent { final String email; final String password; AuthLoginRequested({required this.email, required this.password});
@override List<Object?> get props => [email, password];}
class AuthLogoutRequested extends AuthEvent {}
class AuthTokenRefreshed extends AuthEvent { final String token; AuthTokenRefreshed(this.token);
@override List<Object?> get props => [token];}
// States — what the UI should showsealed class AuthState extends Equatable { @override List<Object?> get props => [];}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class Authenticated extends AuthState { final User user; Authenticated(this.user);
@override List<Object?> get props => [user];}
class AuthError extends AuthState { final String message; AuthError(this.message);
@override List<Object?> get props => [message];}Using sealed classes for events and states gives you exhaustive pattern matching and prevents Unhandled cases at compile time. Equatable provides value equality for events and states, which Prevents the BLoC from emitting duplicate consecutive states.
The BLoC Class
Section titled “The BLoC Class”import 'package:flutter_bloc/flutter_bloc.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> { final AuthRepository _repo; final TokenStore _tokenStore;
AuthBloc({ required AuthRepository repo, required TokenStore tokenStore, }) : _repo = repo, _tokenStore = tokenStore, super(AuthInitial()) { on<AuthLoginRequested>(_onLoginRequested); on<AuthLogoutRequested>(_onLogoutRequested); on<AuthTokenRefreshed>(_onTokenRefreshed); }
Future<void> _onLoginRequested( AuthLoginRequested event, Emitter<AuthState> emit, ) async { emit(AuthLoading()); try { final user = await _repo.login(email: event.email, password: event.password); await _tokenStore.save(user.token); emit(Authenticated(user)); } on AuthException catch (e) { emit(AuthError(e.message)); } }
Future<void> _onLogoutRequested( AuthLogoutRequested event, Emitter<AuthState> emit, ) async { await _tokenStore.clear(); emit(AuthInitial()); }
Future<void> _onTokenRefreshed( AuthTokenRefreshed event, Emitter<AuthState> emit, ) async { try { final user = await _repo.validateToken(event.token); await _tokenStore.save(user.token); emit(Authenticated(user)); } on AuthException { emit(AuthInitial()); } }}Cubit: The Simpler Variant
Section titled “Cubit: The Simpler Variant”Cubit removes the event layer. State transitions happen via direct method calls:
class CounterCubit extends Cubit<int> { CounterCubit() : super(0);
void increment() => emit(state + 1); void decrement() => emit(state - 1); void reset() => emit(0);}Use Cubit when the state transitions are simple (no complex event-driven logic). Use Bloc when You need event tracing, replayability, or complex state machines.
BlocProvider, BlocBuilder, BlocListener, BlocConsumer
Section titled “BlocProvider, BlocBuilder, BlocListener, BlocConsumer”// Provide the BLoC to the subtreeBlocProvider( create: (context) => AuthBloc( repo: context.read<AuthRepository>(), tokenStore: context.read<TokenStore>(), ), child: const AuthPage(),)
// BlocBuilder — rebuilds UI when state changesBlocBuilder<AuthBloc, AuthState>( builder: (context, state) { return switch (state) { AuthLoading() => const CircularProgressIndicator(), Authenticated(:final user) => Text('Welcome, ${user.name}'), AuthError(:final message) => Text('Error: $message'), AuthInitial() => const LoginForm(), }; },)
// BlocListener — reacts to state changes without rebuildingBlocListener<AuthBloc, AuthState>( listener: (context, state) { switch (state) { case Authenticated(): Navigator.pushReplacementNamed(context, '/home'); case AuthError(:final message): ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); default: break; } }, child: const AuthForm(),)
// BlocConsumer — both rebuilds and reactsBlocConsumer<AuthBloc, AuthState>( listener: (context, state) { if (state is Authenticated) { Navigator.pushReplacementNamed(context, '/home'); } }, builder: (context, state) { return switch (state) { AuthLoading() => const CircularProgressIndicator(), AuthError(:final message) => Text('Error: $message'), _ => const LoginForm(), }; },)BlocObserver for Logging
Section titled “BlocObserver for Logging”BlocObserver provides a global hook for logging all BLoC events and state transitions:
class AppBlocObserver extends BlocObserver { @override void onCreate(BlocBase bloc) { super.onCreate(bloc); logger.info('${bloc.runtimeType} created'); }
@override void onEvent(Bloc bloc, Object? event) { super.onEvent(bloc, event); logger.info('${bloc.runtimeType}: $event'); }
@override void onTransition(Bloc bloc, Transition transition) { super.onTransition(bloc, transition); logger.info('${bloc.runtimeType}: ${transition.event} → ${transition.nextState}'); }
@override void onError(BlocBase bloc, Object error, StackTrace stackTrace) { super.onError(bloc, error, stackTrace); logger.error('${bloc.runtimeType}: $error', stackTrace); }
@override void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); logger.info('${bloc.runtimeType}: $change'); }
@override void onClose(BlocBase bloc) { super.onClose(bloc); logger.info('${bloc.runtimeType} disposed'); }}
void main() { Bloc.observer = AppBlocObserver(); runApp(const MyApp());}Testing BLoCs
Section titled “Testing BLoCs”BLoCs are highly testable because they are pure functions from events to states. No widgets needed:
import 'package:bloc_test/bloc_test.dart';
void main() { late AuthBloc bloc; late MockAuthRepository repo; late MockTokenStore tokenStore;
setUp(() { repo = MockAuthRepository(); tokenStore = MockTokenStore(); bloc = AuthBloc(repo: repo, tokenStore: tokenStore); });
tearDown(() => bloc.close());
blocTest<AuthBloc, AuthState>( 'emits [loading, authenticated] on successful login', build: () => bloc, act: (bloc) => bloc.add(AuthLoginRequested( email: "alice@example.com'', password: "password', )), setUp: () { when(repo.login( email: "alice@example.com'', password: "password', )).thenAnswer((_) async => User( name: "Alice'', email: "alice@example.com', token: "token-123'', )); }, expect: () => [ AuthLoading(), Authenticated(User( name: "Alice', email: "alice@example.com'', token: "token-123', )), ], verify: (_) { verify(tokenStore.save('token-123')).called(1); }, );
blocTest<AuthBloc, AuthState>( 'emits [loading, error] on login failure', build: () => bloc, act: (bloc) => bloc.add(AuthLoginRequested( email: "bad@example.com'', password: "wrong', )), setUp: () { when(repo.login( email: "bad@example.com'', password: "wrong', )).thenThrow(AuthException('Invalid credentials')); }, expect: () => [ AuthLoading(), AuthError('Invalid credentials'), ], );}When BLoC Is Appropriate
Section titled “When BLoC Is Appropriate”- Large teams where explicit event contracts prevent “who changed what” confusion.
- Complex state machines with many states and transitions (e.g., multi-step forms, order processing, authentication flows).
- Event tracing requirements — when you need to log, replay, or audit every state transition.
- Testing rigor — when the ability to test state transitions in isolation without widgets is a priority.
The cost is boilerplate. Every feature requires an event class, a state class, and a BLoC class. For Simple features, this overhead is not justified.
Comparison Table
Section titled “Comparison Table”| Factor | setState | InheritedWidget | Provider | Riverpod | BLoC |
|---|---|---|---|---|---|
| Complexity | Trivial | Low | Low | Medium | High |
| Learning curve | None | Low | Low | Medium | High |
| Boilerplate | None | High | Low | Medium (low w/ gen) | High |
| BuildContext needed | Yes (local only) | Yes | Yes | No | Yes (for widgets) |
| Async support | Manual | Manual | Manual | Built-in | Built-in |
| Rebuild granularity | None (full tree) | Manual | Good | Excellent | Good |
| Testability | Poor | Poor | Medium | Excellent | Excellent |
| Scalability | None | Low | Medium | High | High |
| Code generation | No | No | No | Optional | No |
| Ecosystem maturity | N/A (built-in) | N/A (built-in) | Mature | Mature | Mature |
| Recommended for | Local UI state | Foundation only | Small apps | New projects | Large teams |
Choosing the Right Solution
Section titled “Choosing the Right Solution”The decision depends on four factors: app size, team size, state complexity, and async requirements.
Small App / Solo Developer
Section titled “Small App / Solo Developer”Provider or setState. The overhead of Riverpod or BLoC is not justified. Use setState for Local UI state and ChangeNotifierProvider for shared state. Keep it simple.
// This is fine for a small appclass SettingsPage extends StatefulWidget { /* ... */ }class _SettingsPageState extends State<SettingsPage> { bool _darkMode = false; // setState is sufficient here}Medium App / Small Team
Section titled “Medium App / Small Team”Riverpod. The auto-dispose, async support, and testability without widgets pay off as complexity Grows. The learning curve is moderate, and code generation keeps boilerplate manageable.
Large App / Large Team
Section titled “Large App / Large Team”Riverpod or BLoC. Riverpod for flexibility and developer ergonomics. BLoC when the team benefits From explicit event contracts and state machine discipline.
Decision Framework
Section titled “Decision Framework”| Condition | Recommendation |
|---|---|
| Local UI state only | setState |
| Shared state, simple mutations | Provider |
| Shared state, async operations | Riverpod |
| Complex state machine, event tracing needed | BLoC |
| State access outside widgets (routing, etc.) | Riverpod |
| Team requires strict event contracts | BLoC |
| Minimize boilerplate | Riverpod with code generation |
| Already using Provider, want to stay simple | Provider |
The most common mistake is choosing the most complex solution first. Start with setState for local State. Add Provider or Riverpod when state needs to be shared. Introduce BLoC only when the Complexity of the state machine justifies the boilerplate.
Intuition
Section titled “Intuition”Managing app state: State management is like organizing a filing system — it keeps track of all the data your app needs and ensures changes are reflected in the UI.
Why it matters: Complex apps need careful state management to avoid bugs and ensure data consistency across screens and widgets.
The key insight: State should flow in one direction — from data to UI — making your app predictable and easier to debug.
Common Pitfalls
Section titled “Common Pitfalls”1. Using setState for Shared State
Section titled “1. Using setState for Shared State”// WRONG — state is trapped in this widgetclass UserSession extends StatefulWidget { final Widget child; const UserSession({required this.child, super.key});
@override State<UserSession> createState() => _UserSessionState();}
class _UserSessionState extends State<UserSession> { User? _currentUser;
void login(User user) { setState(() => _currentUser = user); }
// Siblings and descendants cannot access _currentUser // You need callbacks, InheritedWidget, or Provider}
// CORRECT — use Provider, Riverpod, or BLoC for shared statefinal userProvider = StateProvider<User?>((ref) => null);2. Context.read() in build() Instead of context.watch()
Section titled “2. Context.read() in build() Instead of context.watch()”// WRONG — reads once, never rebuilds when state changesclass MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { final counter = context.read<Counter>(); // Static read return Text('${counter.count}'); // Never updates }}
// CORRECT — subscribes to changesclass MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { final counter = context.watch<Counter>(); // Reactive subscription return Text('${counter.count}'); // Rebuilds when counter changes }}3. Forgetting notifyListeners() in Provider
Section titled “3. Forgetting notifyListeners() in Provider”// WRONG — state changes but UI does not updateclass Cart extends ChangeNotifier { final List<Item> _items = [];
void addItem(Item item) { _items.add(item); // Missing: notifyListeners(); — UI will not rebuild }
List<Item> get items => List.unmodifiable(_items);}
// CORRECTclass Cart extends ChangeNotifier { final List<Item> _items = [];
void addItem(Item item) { _items.add(item); notifyListeners(); // Triggers rebuild of watching widgets }}4. Creating Providers Inside build()
Section titled “4. Creating Providers Inside build()”// WRONG — creates a new provider on every rebuild, breaking stateclass MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => Counter(), child: const CounterDisplay(), ); }}
// CORRECT — create providers above the widgets that consume them// In a parent widget or main()void main() { runApp( ChangeNotifierProvider( create: (_) => Counter(), child: const MyApp(), ), );}5. Over-Rebuilding with BLoC
Section titled “5. Over-Rebuilding with BLoC”// WRONG — entire page rebuilds on every state changeBlocBuilder<AuthBloc, AuthState>( builder: (context, state) { return Scaffold( appBar: AppBar(title: const Text('Auth')), body: switch (state) { AuthLoading() => const CircularProgressIndicator(), Authenticated(:final user) => UserProfile(user: user), AuthError(:final message) => ErrorView(message: message), AuthInitial() => const LoginForm(), }, bottomNavigationBar: const BottomNav(), // Rebuilds unnecessarily ); },)
// CORRECT — use BlocBuilder only for the part that depends on stateScaffold( appBar: AppBar(title: const Text('Auth')), body: BlocBuilder<AuthBloc, AuthState>( builder: (context, state) { return switch (state) { AuthLoading() => const CircularProgressIndicator(), Authenticated(:final user) => UserProfile(user: user), AuthError(:final message) => ErrorView(message: message), AuthInitial() => const LoginForm(), }; }, ), bottomNavigationBar: const BottomNav(), // Does not rebuild)6. Not Disposing Resources in Riverpod
Section titled “6. Not Disposing Resources in Riverpod”// WRONG — stream subscription leaksfinal tickerProvider = StreamProvider<int>((ref) { return Stream.periodic( const Duration(seconds: 1), (count) => count, ); // When no widget watches this, the stream subscription is never cancelled});
// CORRECT — use autoDisposefinal tickerProvider = StreamProvider.autoDispose<int>((ref) { final controller = StreamController<int>(); final subscription = Stream.periodic( const Duration(seconds: 1), (count) => count, ).listen(controller.add);
ref.onDispose(() { subscription.cancel(); controller.close(); });
return controller.stream;});7. Mixing State Management Solutions
Section titled “7. Mixing State Management Solutions”Using Provider for one feature, Riverpod for another, and raw BLoC for a third creates an Inconsistent codebase where developers must understand three systems. Pick one solution for the app Layer. It is acceptable to use setState for truly local widget state regardless of which solution The app uses — setState is the correct tool for ephemeral, widget-scoped state like animation Progress or a text field controller.
8. Not Extracting State Logic from Widgets
Section titled “8. Not Extracting State Logic from Widgets”// WRONG — business logic lives in the widgetclass OrderPage extends StatefulWidget { @override State<OrderPage> createState() => _OrderPageState();}
class _OrderPageState extends State<OrderPage> { double _subtotal = 0; double _tax = 0; double _shipping = 0;
void _addItem(Item item) { setState(() { _subtotal += item.price; _tax = _subtotal * 0.08; _shipping = _subtotal > 50 ? 0 : 5.99; }); }
double get _total => _subtotal + _tax + _shipping;
@override Widget build(BuildContext context) { return Text('Total: \$_total'); }}
// CORRECT — state logic in a notifier, widget only rendersclass OrderNotifier extends ChangeNotifier { double subtotal = 0; double get tax => subtotal * 0.08; double get shipping => subtotal > 50 ? 0 : 5.99; double get total => subtotal + tax + shipping;
void addItem(Item item) { subtotal += item.price; notifyListeners(); }}9. Equatable Without Proper Props Implementation
Section titled “9. Equatable Without Proper Props Implementation”// WRONG — props is empty, so all instances are "equal"class AuthState extends Equatable { final User? user; final bool isLoading; const AuthState({this.user, this.isLoading = false});
@override List<Object?> get props => []; // BUG: every AuthState equals every other}
// CORRECT — include all fields that affect equalityclass AuthState extends Equatable { final User? user; final bool isLoading; const AuthState({this.user, this.isLoading = false});
@override List<Object?> get props => [user, isLoading];}Without proper propsThe BLoC will skip emitting identical states even when the fields differ, Because Equatable considers them equal.
10. Premature Abstraction
Section titled “10. Premature Abstraction”Do not create a BLoC, notifier, or provider for a feature that is a single screen with two buttons. setState is the correct tool for that. Premature abstraction adds complexity without benefit. Abstract when the state is shared across screens, when the logic is complex enough to test in Isolation, or when multiple widgets need to react to the same state change.
Summary
Section titled “Summary”This topic covers the core concepts of state management, including underlying theory, practical implementation, and key applications.
Key concepts include:
- core concepts and terminology
- algorithms and computational thinking
- practical implementation
- security and ethical considerations
- applications in the real world
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Cross-References
Section titled “Cross-References”- Dart Navigation — Navigation and state management are deeply connected; route state must be synchronised with app state.
- Dart Asynchronous Programming — State changes often involve async operations like network requests or database queries.
- Dart Widgets — Understanding widget lifecycle is essential for choosing the right state management approach.
- Dart Classes and Objects — State management solutions rely on OOP patterns for encapsulating and exposing state.