This is basically a culmination of my experiences so far in state management in Flutter ( where the practices are broadly applicable to any other system as well ).

Immutability and why?

Often articles mention this is good practice and helps to reason… yada yada. For me this is why it matter and when it matters?

Assumptions: There is no extra cost of the immutability and either references are reused or it’s something like move semantics.

  • Adding a logger to emit changes is then very very trivial.
  • You cannot possibly forget when two correlated states change together, this forces you to think about the full state.
  • I can easily know where all in code can the state change be triggered. Instead of object( which could be any name or anywhere ).property which is not easily searchable.
  • invariants on state as a whole are easier to enforce ( instead of doing them on setters of individual properties, which then leads to some duplication of having a function called in multiple places )

So always try use immutable state objects, for related state atleast. If something is one off, you can use your judgement.

Flutter only methods

ChangeNotifier

You have your state class extend ChangeNotifier with class State extends ChangeNotifier and then call notifyListeners() to trigger rebuilds. In Ui, you would then need to keep this state hence a StatefulWidget with a member final State state = State(); and in build a ListenableBuilder with valueListenable: state.

class CounterState extends ChangeNotifier {
  int _count = 0;
  int get count => _count;
 
  void increment() {
    _count++;
    notifyListeners();
  }
}
 
class CounterWidget extends StatefulWidget {
  @override
  _CounterWidgetState createState() => _CounterWidgetState();
}
 
class _CounterWidgetState extends State<CounterWidget> {
  final CounterState state = CounterState();
 
  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: state,
      builder: (context, child) {
        ...
      },
    );
  }
}

What rebuilds? Anything that is listening to the state ( the variable here ) will rebuild when you trigger notifyListeners() from that variable. So multiple different copies of CounterState will not trigger rebuilds in each other. Drawback: You would need to pass this state down the widget tree manually.

ValueNotifier

A ValueNotifier<T> holds a single value of type T and notifies listeners automatically when the value changes, so if you want a fine grained control prefer ChangeNotifier over this.

class CounterNotifier extends ValueNotifier<CounterState> {
    CounterNotifier() : super(CounterState(count: 0));
 
    void increment() {
        value = value.copyWith(count: value.count + 1);
        // or
        // value = CounterState(count: value.count + 1);
    }
}

Listen to it the same way as ChangeNotifier. Same drawback of passing it down the widget tree manually.

InheritedWidget

Think of this as a context inherited ChangeNotifier. There is no automatic listening and you need to wrap the changing widget in a ListenableBuilder still. Same as how Theme.of(context) or MediaQuery.of(context) work. Anything wrapped can access the state via MyState.of(context).

class StateProvider extends InheritedWidget {
  final CounterState state;
 
  StateProvider({required this.state, required Widget child}) : super(child: child);
 
  static CounterState of(BuildContext context) {
    final provider = context.dependOnInheritedWidgetOfExactType<StateProvider>();
    assert(provider != null, 'No StateProvider found in context');
    return provider!.state;
  }
 
  @override
  bool updateShouldNotify(StateProvider oldWidget) {
    return oldWidget.state != state;
  }
}

This above part is basically boilderplate, you can and should template it on <T> to make it reusable. You would then wrap your app or part of the widget tree with this StateProvider and pass the state instance to it.

Note that updateShouldNotify can be very very custom, just like ChangeNotifier as you can use existing state to decide if you want to notify or not.

StateProvider(
  state: CounterState(),
  child: MyApp(),
);

To listen, you would do the same still

final state = StateProvider.of(context);
 
// use state
ListenableBuilder(
  listenable: state,
  builder: (context, child) {
    ...
  },
);

InheritedNotifier

Previous one takes in a state where you define when to notify and need to wrap in a ListenableBuilder. This one takes in a Listenable directly and notifies directly on anything that uses of(context) of this type.

class Provider<T extends Listenable> extends InheritedNotifier<T> {
  Provider({required T notifier, required Widget child}) : super(notifier: notifier, child: child);
 
  static T of<T extends Listenable>(BuildContext context) {
    final provider = context.dependOnInheritedWidgetOfExactType<Provider<T>>();
    assert(provider != null, 'No Provider<$T> found in context');
    return provider!.notifier!;
  }
}

Again this code above is just templated boilerplate. A problem still is that, while you control the notifications, EVERY widget that uses of(context) will rebuild, it’s not selective on say specific properties ( none so far are ).

Provider package

A quick tour basically. Riverpod is the v2 version but if this solves the problem you don’t really want to use that, why use more “magic” and “documentation hell” when not needed? The key difference I can see it the dependence on context in Provider vs no dependence in Riverpod.

The useful types:

  • Provider - takes in a value and exposes it via of(context). Used when constant values and functions only.
  • ChangeNotifierProvider - takes in a ChangeNotifier ( you need to make one separately ) and exposes it via of(context).
  • ValueNotifierProvider - takes in a ValueNotifier<T> and exposes it via of(context). ( Not much useful as you lose the manual control of ChangeNotifier )
  • MultiProvider - takes in multiple providers and exposes them all. Useful to avoid nesting.
  • ProxyProvider - takes in other providers and creates a new provider based on them. Useful when provider A depends on provider B.

Usage in widget tree post wrapping: ALWAYS use a selective listen for most control.

There’s 2 broad ways, one is direcly using context like

  • context.watch<T>() - rebuilds on any change
  • context.read<T>() - does not rebuild, just reads once
  • context.select<T, R>(R Function(T) selector) - rebuilds only when. Example: context.select<CounterState, int>((state) => state.count) rebuilds only when count changes.

Though above can be a bit too verbose when if you are putting it in multiple places. You could wrap it in a builder like

Builder(
  builder: (context) {
    final count = context.select<CounterState, int>((state) => state.count);
    return Text('$count');
  },
);

DO NOT put it in the main build method of a StatelessWidget or StatefulWidget as that would rebuild the whole widget on change.

Now, since you need to wrap anyways, there’s specific widgets that do the same thing

  • Consumer<T> - rebuilds on any change
  • Selector<T, R> - rebuilds specifically. Example: Selector<CounterState, int>(selector: (state) => state.count, builder: (context, count, child) => Text('$count')) rebuilds only when count changes.

Caveat: By default, providers are created lazily, so any init based compute won’t eagerly happen unless you use lazy: false in the provider.

Proxy providers : These are more useful than they seem at first

A provider taking in another provider as a dependency in constructor = ProxyProvider.

Example: StateA and StateB({required StateA a})

In tree,

MultiProvider(
    providers: [
        ChangeNotifierProvider(create: (_) => StateA()),
        ProxyProvider<StateA, StateB>(
            update: (ctxt, a, prevB) => StateB(a: a),
        ),
    ],
    child: MyApp(),
);

Using StateB is same as usual, example context.select<StateB, int>((b) => b.something).

An antipattern is to do in a stateful widget

final a = StateA();
final b = StateB(a: a);
 
@override
Widget build(BuildContext context) {
    return MultiProvider(
        providers: [
            ChangeNotifierProvider(create: (_) => a),
            ChangeNotifierProvider(create: (_) => b),
        ],
        child: MyApp(),
    );
}

Why? Because then lifecycle management and dependency triggers are manual + you need a stateful widget for no reason.

Ideally you always want to create the notifier inside the create itself, unless you know what you are doing.