← Back to blog
Mastering Riverpod: Provider Types · Part 7 of 8
August 14, 20267 min read

Legacy Providers (StateProvider, StateNotifierProvider) — Recognize, Migrate, Move On

RiverpodFlutterDart

Legacy Providers

This is Part 7 of Mastering Riverpod: Provider Types — and it's a bit different. The six providers in the grid are everything you need for new code. But Riverpod has been around for years, so you will encounter older provider types in existing codebases, Stack Overflow answers, and pre-3.0 tutorials. This part's job is simple: recognize them, understand why they're legacy, and know how to migrate to the modern API — then move on.

In Riverpod 3.0 these are officially legacy: StateProvider, StateNotifierProvider, and ChangeNotifierProvider were moved to a separate legacy import path and discouraged for new code (not removed — your old code still works).


The legacy lineup

Three providers are now legacy. Here's what each was, and what replaces it:

| Legacy provider | Was used for | Modern replacement | | --- | --- | --- | | StateProvider | a single simple mutable value (a bool, an int, a filter) | NotifierProvider (Part 4) | | StateNotifierProvider | complex mutable state via a StateNotifier class | NotifierProvider / AsyncNotifierProvider (Parts 4–5) | | ChangeNotifierProvider | bridging Flutter's ChangeNotifier (mutable, listener-based) | NotifierProvider |

To even use them in 3.0 you import the legacy path explicitly, which is itself a signal:

import 'package:flutter_riverpod/legacy.dart'; // legacy providers live here now

StateProvider — and why it's gone

StateProvider was the quick way to expose a single mutable value without writing a class:

// LEGACY — recognize it, don't write it.
final filterProvider = StateProvider<Filter>((ref) => Filter.all);

// Read:  ref.watch(filterProvider)            → Filter
// Write: ref.read(filterProvider.notifier).state = Filter.active;

It was convenient but had real problems: mutation logic leaked into widgets (you set .notifier.state = directly from the UI), there was no natural place for related methods, and it didn't scale past trivial state. The modern Notifier is barely more code and far cleaner.

Migrating StateProviderNotifierProvider

// MODERN — a tiny Notifier with named methods.
class FilterController extends Notifier<Filter> {
  @override
  Filter build() => Filter.all;

  void set(Filter f) => state = f;          // intent-revealing methods
  void showAll() => state = Filter.all;
}

final filterProvider = NotifierProvider<FilterController, Filter>(FilterController.new);

// Read:  ref.watch(filterProvider)          → Filter  (unchanged for consumers!)
// Write: ref.read(filterProvider.notifier).set(Filter.active);

Note: reading is identical (ref.watch(filterProvider)), so consumers barely change. You gain named methods (set, showAll) instead of raw .state = assignments scattered across widgets — mutation logic moves into the controller where it belongs.


StateNotifierProvider — the bigger migration

StateNotifierProvider paired with a StateNotifier<T> class was the previous recommended way to manage complex state. It's the closest in spirit to today's Notifier, but with clunkier ergonomics (you passed initial state to super, ref wasn't a property, etc.):

// LEGACY StateNotifier
class CounterSN extends StateNotifier<int> {
  CounterSN() : super(0);          // initial state via super()
  void increment() => state++;
}
final counterProvider = StateNotifierProvider<CounterSN, int>((ref) => CounterSN());

Migrating StateNotifierNotifier

The mapping is mechanical:

// MODERN Notifier
class Counter extends Notifier<int> {
  @override
  int build() => 0;               // initial state via build() instead of super()
  void increment() => state++;     // methods are unchanged
}
final counterProvider = NotifierProvider<Counter, int>(Counter.new);

The migration rules:

  • StateNotifier<T>Notifier<T>; constructor super(initial)build() returning the initial state.
  • StateNotifierProvider<N, T>((ref) => N())NotifierProvider<N, T>(N.new).
  • Inside the class, ref is now a property (in StateNotifier you had to pass it in) — so reading other providers is cleaner.
  • Async equivalent: a StateNotifier doing async work → an AsyncNotifier (Part 5).
  • Methods (increment, etc.) carry over unchanged.

ChangeNotifierProvider — the most discouraged

ChangeNotifierProvider bridged Flutter's ChangeNotifier (the notifyListeners() mutable-object pattern, also used by the provider package). It was always a compatibility shim, and it's the one to migrate most eagerly because ChangeNotifier encourages mutable state with manual notifyListeners() — the opposite of Riverpod's immutable-state model (Part 4).

Replace it with a Notifier holding immutable state and assigning state = ... instead of mutating fields and calling notifyListeners().


Why the move to Notifier? (The rationale)

Riverpod 3.0 consolidated on the Notifier family for good reasons:

  • One consistent modelNotifier/AsyncNotifier/StreamNotifier mirror the read-only Provider/FutureProvider/StreamProvider, so the whole grid is uniform.
  • ref as a property — cleaner access to other providers from inside the class.
  • Better code-gen support — the @riverpod annotation (a later series) generates Notifier-based code.
  • Immutable-state disciplineNotifier's state = model steers you away from the in-place mutation that ChangeNotifier invites.
  • Unified auto-dispose (Foundation Part 1) — fewer duplicated classes.

You don't have to rewrite a working app overnight — legacy providers still function. But for new code, always reach for the Notifier family, and migrate legacy providers opportunistically when you touch that code.


A migration cheat-sheet

StateProvider<T>              → NotifierProvider<C, T>  (C extends Notifier<T>)
StateNotifier<T> + provider   → Notifier<T> + NotifierProvider   (super(x) → build())
StateNotifier (async)         → AsyncNotifier<T> + AsyncNotifierProvider
ChangeNotifierProvider        → NotifierProvider with immutable state (no notifyListeners)
import '.../legacy.dart'      → import '.../flutter_riverpod.dart'  (drop the legacy import)

Practice Challenges

Challenge 1 — Recognize it. Is StateProvider<bool>((ref) => false) modern or legacy, and what replaces it?

Show solution

Legacy. Replace with a NotifierProvider whose Notifier<bool> exposes a toggle()/set() method instead of writing .notifier.state = from widgets.

Challenge 2 — Migrate a StateProvider. Convert final countP = StateProvider<int>((ref) => 0); to modern form with an increment().

Show solution
class CountC extends Notifier<int> {
  @override
  int build() => 0;
  void increment() => state++;
}
final countP = NotifierProvider<CountC, int>(CountC.new);

Consumers still ref.watch(countP); writes become ref.read(countP.notifier).increment().

Challenge 3 — Migrate a StateNotifier. Convert this:

class TodosSN extends StateNotifier<List<Todo>> {
  TodosSN() : super([]);
  void add(Todo t) => state = [...state, t];
}
final p = StateNotifierProvider<TodosSN, List<Todo>>((ref) => TodosSN());
Show solution
class Todos extends Notifier<List<Todo>> {
  @override
  List<Todo> build() => [];
  void add(Todo t) => state = [...state, t];
}
final p = NotifierProvider<Todos, List<Todo>>(Todos.new);

super([])build() => []; provider uses Todos.new; the method is unchanged.

Challenge 4 — Spot the import. You see import 'package:flutter_riverpod/legacy.dart';. What does it tell you?

Show solution

The file uses legacy providers (StateProvider/StateNotifierProvider/ChangeNotifierProvider), which moved to the legacy import in 3.0. It's a flag to migrate this code to the Notifier family and drop the legacy import.

Challenge 5 — ChangeNotifier red flag. Why migrate ChangeNotifierProvider most eagerly?

Show solution

ChangeNotifier relies on mutating fields in place and calling notifyListeners() — the opposite of Riverpod's immutable state = model (Part 4). Replacing it with a Notifier holding immutable state gives you predictable update-filtering (==) and aligns with the rest of Riverpod.


Questions to test yourself

Q1 (basic). Which three providers are legacy in Riverpod 3.0?

Show answer

StateProvider, StateNotifierProvider, and ChangeNotifierProvider — moved to the legacy import and discouraged for new code (still functional).

Q2 (basic). What is StateProvider replaced by?

Show answer

NotifierProvider (with a small Notifier class exposing named mutation methods instead of writing .notifier.state = from widgets).

Q3 (intermediate). When migrating StateNotifier to Notifier, what replaces super(initialState)?

Show answer

Overriding build() to return the initial state. (StateNotifierProvider<N,T>((ref)=>N()) also becomes NotifierProvider<N,T>(N.new), and methods carry over unchanged.)

Q4 (intermediate). Do consumers change much when you migrate StateProviderNotifierProvider?

Show answer

Not for readingref.watch(provider) is unchanged. Writes change from ref.read(provider.notifier).state = x to calling a named method like ref.read(provider.notifier).set(x). So consumer impact is minimal and mostly improves clarity.

Q5 (intermediate). What's the async equivalent when migrating a StateNotifier that did network work?

Show answer

An AsyncNotifier (Part 5) — async build(), state as AsyncValue, mutations via state = AsyncLoading()AsyncValue.guard(...).

Q6 (advanced). Give two reasons Riverpod 3.0 consolidated on the Notifier family.

Show answer

Any two of: (1) a uniform model mirroring the read-only providers (Notifier/AsyncNotifier/StreamNotifierProvider/FutureProvider/StreamProvider); (2) ref as a class property for cleaner access to dependencies; (3) better code-generation support via @riverpod; (4) immutable-state discipline (vs ChangeNotifier's notifyListeners()); (5) unified auto-dispose removing duplicated classes.


Wrapping up

Legacy providers are for recognizing, not writing:

  • StateProvider, StateNotifierProvider, ChangeNotifierProvider are legacy in 3.0 (moved to the legacy import, still functional).
  • Migrate them to the Notifier family: StateProviderNotifierProvider; StateNotifierNotifier/AsyncNotifier (super(x)build(), N.new); ChangeNotifierNotifier with immutable state.
  • Reading consumers barely change; writes become named methods — clearer and more testable.
  • For new code, always use the modern grid; migrate legacy opportunistically.

That completes a tour of every provider type — read-only and mutable, sync, Future, and Stream, plus the legacy ones. Time to prove it. Part 8 is the 100-question Riverpod Provider Types mastery bank with coding mini-exercises.