Week 6: State Management I — setState, InheritedWidget & ValueNotifier

Before reaching for Provider or Riverpod next week, it's worth understanding exactly what those libraries are built on top of — because the problems they solve are real problems you can feel directly by hitting them with nothing but setState first.

Phase 3 of 8 Week 6 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain precisely what setState rebuilds, and why that stops scaling past one widget
  • Use InheritedWidget to make data available anywhere below it in the tree without prop drilling
  • Use ValueNotifier and ChangeNotifier to model observable state
  • Recognize the shape every Flutter state management library eventually reduces to

1. setState & Why It Doesn't Scale

setState does exactly one thing: it tells Flutter "this widget's state changed, rebuild it (and its descendants)." That's precisely calibrated for state that's genuinely local to one widget — but it has a hard structural limit.

the problem — cart count needed on two unrelated screens
class ProductListScreen extends StatefulWidget { /* ... */ }
class _ProductListScreenState extends State<ProductListScreen> {
  int _cartCount = 0; // lives here...

  void _addToCart() => setState(() => _cartCount++);

  // ...but the bottom nav bar (a sibling, not a descendant) also needs
  // _cartCount to show a badge. setState alone has no way to reach it —
  // there's no shared ancestor holding the state both widgets can see.
}

The moment two widgets that aren't in a direct parent-child relationship need the same piece of state, setState alone runs out of road — the state has to live somewhere both can reach, which means moving it up to a common ancestor and finding a way to get it back down without manually threading it through every layer in between ("prop drilling").

2. InheritedWidget

InheritedWidget is Flutter's built-in mechanism for making data available to any descendant, however deep, without passing it explicitly through every constructor along the way.

a minimal InheritedWidget
class CartCount extends InheritedWidget {
  final int count;
  const CartCount({super.key, required this.count, required super.child});

  static int of(BuildContext context) {
    final widget = context.dependOnInheritedWidgetOfExactType<CartCount>();
    return widget!.count;
  }

  @override
  bool updateShouldNotify(CartCount oldWidget) => count != oldWidget.count;
}
reading it from anywhere below, with no constructor threading
final count = CartCount.of(context); // works from any descendant, any depth

context.dependOnInheritedWidgetOfExactType is what makes this efficient — it registers the calling widget as a dependent, so only widgets that actually read CartCount rebuild when it changes, not the entire subtree underneath it.

3. ValueNotifier & ChangeNotifier

InheritedWidget solves getting data down the tree; it doesn't itself solve mutating that data cleanly. ValueNotifier and ChangeNotifier are the standard building blocks for observable, mutable state that pairs naturally with it.

ValueNotifier — a single observable value
final cartCount = ValueNotifier<int>(0);

// anywhere that needs to react to changes:
ValueListenableBuilder<int>(
  valueListenable: cartCount,
  builder: (context, count, child) => Badge(label: Text('$count')),
)

// mutating it — every listener rebuilds automatically
cartCount.value++;
ChangeNotifier — several related fields, one notifier
class CartModel extends ChangeNotifier {
  final List<String> _items = [];
  List<String> get items => List.unmodifiable(_items);

  void add(String item) {
    _items.add(item);
    notifyListeners(); // tells every listener to rebuild
  }
}

This is the exact shape Provider and Riverpod are built on top of — ChangeNotifier plus a mechanism to make one instance reachable from anywhere in the tree (which is precisely what InheritedWidget provides) is, underneath the API sugar, what next week's libraries actually are.

4. Hands-on Exercise

Hands-on

Build shared cart state with nothing but this week's primitives

Solve the sibling-state problem from this week's first section using only InheritedWidget and ChangeNotifier — no Provider yet.

Requirements:

  1. A CartModel (ChangeNotifier) holding a list of items, with add and remove methods calling notifyListeners().
  2. A custom InheritedWidget exposing the CartModel instance to descendants, with a static .of(context) accessor.
  3. A product screen that adds items to the cart, and a completely separate bottom-nav-bar badge widget that shows the current item count — both reading the same CartModel instance with no direct parent-child relationship.
  4. Confirm that adding an item updates the badge without manually calling setState anywhere outside the model itself.
  5. A short comment identifying, in your own implementation, which specific problem from the first section (sibling widgets needing the same state) this design actually solves.
Hint

If the badge widget doesn't update when the cart changes, confirm it's actually listening — reading CartModel's current value once via InheritedWidget.of(context) only gets a snapshot at build time; wrap the badge in an AnimatedBuilder or ListenableBuilder with listenable: cartModel so it rebuilds specifically when notifyListeners() fires.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Precisely what does setState tell Flutter to do, and why does that stop being enough once two sibling widgets need the same state?

setState only tells Flutter to rebuild the widget it was called on (and its descendants) — it has no mechanism to notify a widget that isn't in that subtree at all. Two siblings, by definition, aren't descendants of each other, so no amount of calling setState in one can ever reach the other; the state has to move to a shared ancestor both can actually see.

Q2

What does context.dependOnInheritedWidgetOfExactType register that a plain widget tree lookup wouldn't, and why does that matter for performance?

It registers the calling widget as a dependent of that specific InheritedWidget, so when updateShouldNotify returns true, only widgets that actually called this method are rebuilt — not the entire subtree below the InheritedWidget. Without that targeted tracking, any change would have to conservatively rebuild everything underneath, which doesn't scale to a large tree.

Q3

What's the practical difference between ValueNotifier and ChangeNotifier, and when would you reach for one over the other?

ValueNotifier wraps exactly one observable value and notifies listeners whenever that single value is reassigned. ChangeNotifier is a base class for a model with multiple fields or more complex internal state, where you call notifyListeners() explicitly whenever any of it changes — reach for ValueNotifier for one simple value, ChangeNotifier once there's real internal structure to manage.

Q4

In what sense are Provider and Riverpod (covered next week) "built on top of" this week's primitives rather than something fundamentally different?

Underneath their APIs, both still rely on ChangeNotifier-shaped observable state and a tree-based mechanism (conceptually the same problem InheritedWidget solves) for making that state reachable from anywhere below a given point in the widget tree. They add ergonomics, dependency injection, and testability on top — but the core mechanics are exactly what this week built by hand.