Intermediate · Lesson 1 · 14 min read
State Management Beyond setState
Understand InheritedWidget, then compare Provider, Riverpod and BLoC honestly so you can pick one and move on.
Updated July 25, 2026
What you will learn
- Explain how InheritedWidget powers every state solution in Flutter
- Share state with ChangeNotifier and Provider
- Compare Provider, Riverpod and BLoC on real criteria
- Avoid the rebuild mistakes that make shared state slow
Once state is needed by widgets that are not neighbours, passing callbacks down the tree stops scaling. Every solution to this problem in Flutter — Provider, Riverpod, BLoC, GetX — is built on the same primitive: InheritedWidget.
The primitive: InheritedWidget
An InheritedWidget sits above a subtree and lets any descendant read it in O(1) via context.dependOnInheritedWidgetOfExactType. Crucially, widgets that read it are automatically rebuilt when it changes — and only those widgets.
class AppSettings extends InheritedWidget {
const AppSettings({
super.key,
required this.themeMode,
required this.setThemeMode,
required super.child,
});
final ThemeMode themeMode;
final ValueChanged<ThemeMode> setThemeMode;
static AppSettings of(BuildContext context) {
final result = context.dependOnInheritedWidgetOfExactType<AppSettings>();
assert(result != null, 'No AppSettings found in context');
return result!;
}
// Called on rebuild: should dependents be notified?
@override
bool updateShouldNotify(AppSettings oldWidget) =>
themeMode != oldWidget.themeMode;
}
// Any descendant, at any depth:
final mode = AppSettings.of(context).themeMode;This is exactly how Theme.of(context), MediaQuery.of(context) and Navigator.of(context) work. Writing one by hand is worth doing once — after that, use a package, because the boilerplate for mutable state gets tedious fast.
Provider + ChangeNotifier
Provider is a thin, well-understood wrapper over InheritedWidget. ChangeNotifier is a simple observable: mutate fields, call notifyListeners(), and listening widgets rebuild.
import 'package:flutter/foundation.dart';
class CartModel extends ChangeNotifier {
final List<Product> _items = [];
// Expose an unmodifiable view — callers must go through add/remove
List<Product> get items => List.unmodifiable(_items);
int get count => _items.length;
double get total => _items.fold(0, (sum, p) => sum + p.price);
void add(Product product) {
_items.add(product);
notifyListeners();
}
void remove(Product product) {
if (_items.remove(product)) {
notifyListeners();
}
}
}void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CartModel()),
Provider<ApiClient>(create: (_) => ApiClient()),
],
child: const MyApp(),
),
);
}// Rebuilds this widget whenever the cart changes
final cart = context.watch<CartModel>();
Text('${cart.count} items');
// Reads once without subscribing — correct inside callbacks
onPressed: () => context.read<CartModel>().add(product),
// Rebuilds only when the selected slice changes
final count = context.select<CartModel, int>((c) => c.count);Modelling async state properly
The single biggest quality jump in intermediate Flutter code is representing loading, data and error as one value instead of three loose booleans.
sealed class AsyncState<T> {
const AsyncState();
}
class AsyncLoading<T> extends AsyncState<T> {
const AsyncLoading();
}
class AsyncData<T> extends AsyncState<T> {
const AsyncData(this.value);
final T value;
}
class AsyncError<T> extends AsyncState<T> {
const AsyncError(this.message);
final String message;
}
// In the widget — the compiler enforces that you handle every case
Widget build(BuildContext context) {
final state = context.watch<LessonsModel>().state;
return switch (state) {
AsyncLoading() => const Center(child: CircularProgressIndicator()),
AsyncError(message: final m) => ErrorView(message: m, onRetry: _reload),
AsyncData(value: final lessons) => LessonList(lessons: lessons),
};
}With bool isLoading plus String? error plus List<T>? data you can represent nonsense — loading and errored and holding data. A sealed class makes the impossible states unrepresentable.
Choosing a solution
| Approach | Strengths | Costs | Good fit |
|---|---|---|---|
setState | Zero dependencies, obvious | Does not share across the tree | Widget-local state, always |
| Provider | Small API, huge ecosystem, easy to learn | Depends on BuildContext; manual dispose patterns | Most small and medium apps |
| Riverpod | Compile-safe, testable without a widget tree, auto-disposal | More concepts up front; codegen recommended | Medium to large apps, teams |
| BLoC | Explicit events, superb traceability, strong conventions | Most boilerplate | Large teams, complex flows, heavy auditing |
There is no wrong answer among these, and switching later is a mechanical refactor if you keep business logic out of widgets. What actually matters is picking one per app and being consistent.
Riverpod in thirty seconds
// A provider is a global, but a testable and overridable one
final cartProvider = NotifierProvider<CartNotifier, List<Product>>(
CartNotifier.new,
);
class CartNotifier extends Notifier<List<Product>> {
@override
List<Product> build() => [];
void add(Product p) => state = [...state, p];
void remove(Product p) => state = state.where((x) => x != p).toList();
}
// In a ConsumerWidget
class CartBadge extends ConsumerWidget {
const CartBadge({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(cartProvider.select((items) => items.length));
return Badge(label: Text('$count'), child: const Icon(Icons.shopping_cart));
}
}Note that state is replaced, never mutated: state = [...state, p]. Immutable updates make comparison cheap and change tracking reliable — the same discipline pays off in every solution here.
Keeping rebuilds cheap
- Put the listener as low in the tree as possible — a
Consumeraround just the badge, not around the whole screen. - Use
selectto depend on one field rather than the whole model. - Mark unchanging subtrees
constso they are skipped entirely. - Use the
childparameter ofConsumer/ValueListenableBuilderto pass through a subtree that does not depend on the state.
Consumer<CartModel>(
// 'child' is built once and reused across every rebuild
child: const ExpensiveStaticHeader(),
builder: (context, cart, child) => Column(
children: [
child!,
Text('Total: ${cart.total}'),
],
),
)Key takeaways
- Every state solution in Flutter is InheritedWidget underneath — learn that first.
watchin build,readin callbacks,selectfor one field.- Model async state as a sealed class so impossible states cannot be represented.
- Provider, Riverpod and BLoC are all valid; consistency matters more than the choice.
Practice
Refactor prop drilling away
Take the cart example from the beginner state lesson and move the cart into a ChangeNotifier shared with Provider. The cart badge in the app bar and the total at the bottom should both update without the screen widget passing anything down. Verify with DevTools that tapping 'add' does not rebuild the product grid.
Show hints
- Wrap only the badge in a
Consumer, not the wholeScaffold. context.selectoncountkeeps the badge from rebuilding on price changes.