Skip to content
FlutterLearn

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.

Dart
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.

lib/state/cart_model.dart
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();
    }
  }
}
lib/main.dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => CartModel()),
        Provider<ApiClient>(create: (_) => ApiClient()),
      ],
      child: const MyApp(),
    ),
  );
}
Dart
// 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.

Dart
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

ApproachStrengthsCostsGood fit
setStateZero dependencies, obviousDoes not share across the treeWidget-local state, always
ProviderSmall API, huge ecosystem, easy to learnDepends on BuildContext; manual dispose patternsMost small and medium apps
RiverpodCompile-safe, testable without a widget tree, auto-disposalMore concepts up front; codegen recommendedMedium to large apps, teams
BLoCExplicit events, superb traceability, strong conventionsMost boilerplateLarge 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

Dart
// 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 Consumer around just the badge, not around the whole screen.
  • Use select to depend on one field rather than the whole model.
  • Mark unchanging subtrees const so they are skipped entirely.
  • Use the child parameter of Consumer/ValueListenableBuilder to pass through a subtree that does not depend on the state.
Dart
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.
  • watch in build, read in callbacks, select for 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 whole Scaffold.
  • context.select on count keeps the badge from rebuilding on price changes.