Riverpodで画面の状態を持たせるとき、いまの標準は NotifierProvider です。かつての定番だった StateNotifierProvider は、Riverpod 3.0 で legacy(旧API)扱いになり、別の import に移されました。この記事では、NotifierProvider の書き方を最小のコードから示し、StateNotifierProvider との違い、既存コードを移すときに書き換える箇所、移行後に挙動が変わる点を説明します。動作は 2026年10月時点の最新版 riverpod 3.4.3 を Dart 3.13.5 で実行して確かめています。
まとめ:NotifierProviderとStateNotifierProviderの違いと移行の結論
- 新しく書く状態は NotifierProvider(非同期なら AsyncNotifierProvider)で書きます。StateNotifierProvider と StateProvider は 3.0 から
package:flutter_riverpod/legacy.dartへ移され、新規に選ぶ理由はありません。 - 違いの中心は初期化の置き場所です。Notifier は初期値と依存の
ref.watchをbuild()に集め、refはクラスのプロパティとして使えます。StateNotifier はコンストラクタのsuper(初期値)とプロバイダ定義に処理が分かれます。 - 既存の StateNotifier は legacy の import を足せばそのまま動きます。非推奨の警告も出ないので、触る機会のあるクラスから1つずつ移せば足ります。
- 移行で挙動が変わる点が3つあります。通知の判定が
identicalから==に変わる、dispose()とmountedが無くなる、引数はFamilyNotifierではなくコンストラクタで受ける、の3点です。 - await のあとに
ref.mountedを見るだけでは、依存の変化による作り直しを検知できません。Notifier は作り直しでも同じインスタンスのまま残るためで、対処は後半の章で示します。
NotifierProviderの書き方:Notifierクラスから画面での参照まで
build()で初期状態を返すNotifierクラス
Notifier<T> を継承し、build() の戻り値を初期状態にします。状態の変更は state への代入です。ほかのプロバイダを読むときは、クラスが持つ ref をそのまま使います。状態の初期化や依存の購読は build() に書き、familyの引数をフィールドに保持する処理はコンストラクタに書けます。
import 'package:flutter_riverpod/flutter_riverpod.dart';
class Counter extends Notifier<int> {
@override
int build() => 0; // 初期状態
void increment() => state++;
void reset() => state = 0;
}
// 型引数は <Notifierの型, 状態の型>。生成関数にはコンストラクタ参照を渡す
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
手書きの NotifierProvider は既定で自動破棄(autoDispose)されません。画面を離れたら状態を捨てたい場合は NotifierProvider.autoDispose<Counter, int>(Counter.new) と書きます。
ref.watchとref.read(.notifier)の使い分け
画面側では、表示に使う値を ref.watch(counterProvider) で購読し、ボタンなどのイベントでは ref.read(counterProvider.notifier) で Notifier を取り出してメソッドを呼びます。.notifier を付けると状態の値ではなく Notifier インスタンスが返ります。watch はウィジェットのビルド中に購読を宣言するためのAPIなので、イベント処理ではread にします。
import 'package:flutter/material.dart';
class CounterView extends ConsumerWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('$count'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Text('増やす'),
),
],
);
}
}
アプリ全体は runApp(const ProviderScope(child: MyApp())) のように ProviderScope で包みます。パッケージの導入から ProviderScope の配置までは、Riverpodとは?Flutterの状態管理をRiverpod 3系で始める導入・使い方・移行ガイドで手順を追っています。
NotifierProviderとStateNotifierProviderの違い
どちらも「状態を持つクラスをプロバイダで公開する」仕組みで、画面側の ref.watch(provider) と ref.read(provider.notifier) の書き方は同じです。違うのはクラスの中身と、状態を変えたときの通知の条件です。
| 観点 | NotifierProvider | StateNotifierProvider |
|---|---|---|
| 初期状態 | build() の戻り値 | super(初期値) |
| 依存の ref.watch | build() の中 | プロバイダ定義の中 |
| ref の入手 | クラスのプロパティ | コンストラクタ引数 |
| 後片付け | ref.onDispose | dispose() の上書き |
| 通知の判定 | == | !identical |
| 引数付き | コンストラクタで受ける | .family の生成関数 |
| @riverpod 生成 | 対応 | 非対応 |
| 3.x の import | flutter_riverpod.dart | legacy.dart |
StateNotifierとNotifierの初期化・依存の配置
StateNotifier では、依存するプロバイダの ref.watch をプロバイダ定義に書き、その値と ref をコンストラクタへ渡し、初期化処理はコンストラクタに書く、という3か所に処理が散ります。公式のFrom StateNotifier の移行ガイドは、タイマーと後片付けを持つ例で「挙動を理解するのに8か所を見る必要がある」と書いています。Notifier ではこれが全部 build() に入るので、依存が変わったときに何が再実行されるかを1か所で読めます。
更新通知の判定:identicalから==へ
見落としやすいのが通知の条件です。StateNotifier の updateShouldNotify は !identical(old, current)(同じインスタンスでなければ通知)で、Riverpod 3.0 の Notifier はすべて ==(等しくなければ通知)で判定します。中身の等しい新しいレコード (name: 'a', age: 1) を代入して通知回数を数えると、StateNotifierProvider は1回、NotifierProvider は0回でした。
freezed などで == を実装した状態クラスを使っていると、移行後は「同じ内容で作り直して再通知させる」書き方が効かなくなります。逆に、リストを state.add(2) で直接書き換えて state = state と再代入しても、同じインスタンスなので通知は0回です。state = [...state, 3] と新しいリストを作れば1回届きます。どうしても通知の条件を変えたいときは Notifier 側で updateShouldNotify を上書きします(true を返すようにすると、同じ値の代入でも1回通知されました)。
StateNotifierProviderの3系での扱い:legacy.dartへの移動
公式の3.0移行ガイドは、StateProvider・StateNotifierProvider・ChangeNotifierProvider を legacy と位置づけ、使い続けるなら legacy 用の import に変えるよう案内しています。削除はされていません。
// Flutter アプリではこの1行に揃える
import 'package:flutter_riverpod/legacy.dart';
class CounterOld extends StateNotifier<int> {
CounterOld() : super(0);
void increment() => state++;
}
final counterOldProvider =
StateNotifierProvider<CounterOld, int>((ref) => CounterOld());
import 先は2種類あり、中身が違います。flutter_riverpod 3.4.3 の legacy.dart は ChangeNotifierProvider を含みますが、riverpod 側の legacy.dart は StateNotifier 系と StateProvider だけです。Flutter アプリでは flutter_riverpod 側を使います。
legacy の API には @Deprecated 注釈が付いていないため、dart analyze を通しても非推奨の警告は出ません。警告を頼りに移行対象を洗い出すことはできないので、legacy.dart を import しているファイルを検索して一覧にします。StateNotifier クラス自体は別パッケージ state_notifier のもので、その最新版 1.0.0 は2023年8月16日の公開から更新されていません。
StateProvider も同じく legacy です。「値を1つ持つだけ」の用途でも、build() で初期値を返し、変更用のメソッドを1つ持つ Notifier に置き換えます。
StateNotifierからNotifierへ移行する手順
画面側のコードは変えずに済み、書き換えはクラスとプロバイダ定義に集まります。公式ガイドの論点を、書き換える順に並べます。
初期値と依存のbuild()への移動
super(初期値) を build() の戻り値にし、プロバイダ定義にあった ref.watch も build() に移します。コンストラクタ引数で受けていた ref は削除し、クラスの ref を使います。
// 移行前
class TodoListOld extends StateNotifier<List<String>> {
TodoListOld(this.ref, this.limit) : super(const []);
final Ref ref;
final int limit;
}
final todoListOldProvider =
StateNotifierProvider<TodoListOld, List<String>>((ref) {
final limit = ref.watch(limitProvider);
return TodoListOld(ref, limit);
});
// 移行後
class TodoList extends Notifier<List<String>> {
@override
List<String> build() {
ref.watch(limitProvider); // 依存もここで読む。上限が変わると build() をやり直す
return const [];
}
void add(String title) {
if (state.length >= ref.read(limitProvider)) return;
state = [...state, title]; // 新しいリストを代入する
}
}
final todoListProvider =
NotifierProvider<TodoList, List<String>>(TodoList.new);
dispose()とmountedからref.onDispose・ref.mountedへの置き換え
Notifier には dispose() の上書きも mounted プロパティもありません。タイマーや購読の後片付けは、build() の中で ref.onDispose(timer.cancel) のように登録します。build() が再実行される前にも呼ばれるので、作り直しのたびに古いタイマーが止まります。if (mounted) は Riverpod 3.0 で追加された ref.mounted に置き換えます。autoDispose のプロバイダを await 中に破棄させると ref.mounted は false になり、確認せずに state へ代入した場合は UnmountedRefException が投げられました。
状態変更の監視:addListener・streamからlistenSelfへの移行
StateNotifier の addListener と stream で状態の変化を拾っていた処理は、Notifier の中なら build() で listenSelf((previous, next) { ... }) を呼びます。実行すると、初期化時に null -> 0、更新時に 0 -> 1 が届きました。Notifier の外から監視する場合は、ほかのプロバイダと同じく ref.listen を使います。debugState は state_notifier 1.0.0ですでに削除されています。Notifier移行後のテストの書き方は後述の章で示します。
familyの引数を受け取るコンストラクタ
Riverpod 2系にあった FamilyNotifier は 3.0 で削除されました。引数は Notifier のコンストラクタで受け、NotifierProvider.family にコンストラクタ参照を渡します。
class TodoTitle extends Notifier<String> {
TodoTitle(this.id); // 引数はコンストラクタで受ける
final String id;
@override
String build() => 'todo-$id';
}
// 型引数は <Notifierの型, 状態の型, 引数の型>
final todoTitleProvider =
NotifierProvider.autoDispose.family<TodoTitle, String, String>(TodoTitle.new);
// 使う側: ref.watch(todoTitleProvider('a'))
公式の family のガイドは、引数が変わったあとに古い状態が残り続けないよう、family には自動破棄を付けることを「highly advised」としています。引数ごとの状態を画面を離れても保持したい場合だけ .autoDispose を外します。todoTitleProvider('a') と todoTitleProvider('b') は別々の状態として保持され、実行結果は todo-a と todo-b でした。2系の FamilyNotifier<int, String> で build(String arg) と書いていたコードは、コンストラクタへの引数の移動と build() の引数の削除が要ります。
Notifierのライフサイクル:再ビルド時のインスタンス保持
依存しているプロバイダの値が変わると build() が再実行されますが、Notifier のインスタンスは作り直されません。build() の中でフィールド builds を1ずつ増やす Notifier を用意し、依存の変更と invalidate を順に行うと、identityHashCode は3回とも同じで、builds は 1→2→3 と増え続けました。一方、state は build() の戻り値(0)に戻ります。
この挙動は開発版の途中で一度変わっています。riverpod の変更履歴では、3.0.0-dev.12 で「rebuild のたびに Notifier を作り直す」変更が入り、3.0.0-dev.16(2025年6月20日)で「Revert Notifier life-cycle change. They are once again preserved across rebuilds.」と差し戻されました。開発版の時期に書かれた解説には「作り直される」とあるものが残っているので、安定版の挙動として読まないでください。
実務で効いてくるのは、Notifier のフィールドに置いた値は build() の再実行では消えない、という点です。キャッシュや進行中フラグをフィールドに持たせるなら、build() の先頭で初期化し直すか、状態そのものに含めて state で管理します。
await後のref.mountedが作り直しを検知しない落とし穴
前の章の性質から、もう1つの問題が出てきます。Notifier のメソッドで await している間に依存が変わって build() が再実行されると、メソッドの中の ref は新しいビルドの ref を指すため、ref.mounted は true を返します。次のコードで依存を変えた直後に await が明けると、build() が返した新しい状態(10)が、古い処理の書いた 99 で上書きされました。
class Profile extends Notifier<int> {
@override
int build() => ref.watch(depProvider) * 10;
// 依存が変わっても ref.mounted は true のまま。新しい状態を 99 で上書きする
Future<void> saveNaive() async {
await Future.delayed(const Duration(milliseconds: 50));
if (!ref.mounted) return;
state = 99;
}
// await の前に ref を変数に取っておくと、作り直し後は false になる
Future<void> save() async {
final currentRef = ref;
await Future.delayed(const Duration(milliseconds: 50));
if (!currentRef.mounted) return; // 古いビルドの処理なので捨てる
state = 99;
}
}
save() の書き方では、await 前に取った ref と現在の ref が別物になり、currentRef.mounted は false でした。結果として状態は新しいビルドの 10 のまま残ります。変更履歴の 3.0.0-dev.12 には「When a provider is rebuilt, a new Ref is now created.」とあり、ビルドごとに ref が別物になるのは仕様です。作り直し後の古い ref が true を返していた不具合は 3.2.0 で直っているので、この書き方は 3.2.0 以降が前提です。
画面を閉じたときの破棄だけを気にするなら ref.mounted の確認で足ります。検索条件やログインユーザーのように、await 中に変わりうるプロバイダを build() で watch している Notifier では、await 前に ref を取る書き方にしてください。古い検索条件の結果が新しい条件の画面に出る、という不具合をこれで防げます。
AsyncNotifierProviderで非同期の状態を持つ書き方
API取得のように読み込み中とエラーを持つ状態は、AsyncNotifier と AsyncNotifierProvider で書きます。build() の戻り値型は FutureOr<T> で、同期値または Future<T> を返せます。state は AsyncValue<T> になります。build() で投げた例外は自動で AsyncError になるので、公式ガイドが勧めるとおり build() には try/catch も AsyncValue.guard も書きません。
class Articles extends AsyncNotifier<List<String>> {
@override
Future<List<String>> build() async {
return ref.watch(articleRepositoryProvider).fetchAll();
}
Future<void> add(String title) async {
final previous = state.value;
if (previous == null) return; // 初回の読み込みが終わるまでは追加しない
final currentRef = ref;
final repository = ref.read(articleRepositoryProvider);
state = const AsyncLoading(); // 前回の一覧は value に残る
final result = await AsyncValue.guard(() async {
await repository.add(title);
return [...previous, title];
});
if (!currentRef.mounted) return; // 待機中に作り直されたら結果を捨てる
state = result;
}
}
final articlesProvider =
AsyncNotifierProvider<Articles, List<String>>(Articles.new);
add() では、前の章と同じく await の前に ref を取っておき、待機中にリポジトリが差し替わって build() が再実行されたら結果を捨てます。3.4.3 では state = const AsyncLoading() と代入しても前回の値は消えず、通知された状態は AsyncLoading(value: [a])(hasValue が true)でした。guard の中で例外が出た場合も、AsyncError の value には前回の一覧が残ります。エラーのときも直前の一覧を出したままにしたいなら、when の error 分岐に任せず、先に value の有無を見て一覧とエラー表示を併せて出します。
Notifierのテスト:ProviderContainer.testとoverrideWithBuild
Notifier はコンストラクタで直接作るのではなく、プロバイダ経由で取り出します。手で Counter() と作ると ref が初期化されず使えません。3.0 で追加された ProviderContainer.test() は、テスト終了時にコンテナを自動で破棄します。overrideWithBuild を使うと、Notifier のメソッドは本物のまま、build() の初期値だけを差し替えられます。
import 'package:riverpod/riverpod.dart';
import 'package:test/test.dart';
class Counter extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
void main() {
test('increment で 1 増える', () {
final container = ProviderContainer.test();
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
test('build だけ差し替えて初期値 10 から始める', () {
final container = ProviderContainer.test(
overrides: [counterProvider.overrideWithBuild((ref, notifier) => 10)],
);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 11);
});
}
この2件は、CounterとcounterProviderをテストファイル内に定義し、Riverpod本体を読み込む構成で dart test を実行して通過しました。Flutter のウィジェットテストでは flutter_test の test を使い、ウィジェットを ProviderScope で包みます。
コード生成(@riverpod)で書くNotifier
riverpod_generator を使うと、クラスに @riverpod を付けるだけでプロバイダの宣言が生成されます。2026年10月時点の組み合わせは riverpod_annotation 4.0.7 と riverpod_generator 4.0.9 です。
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'todo_title.g.dart';
@riverpod
class TodoTitle extends _$TodoTitle {
@override
String build(String id) => 'todo-$id'; // コード生成では引数を build に書く
void rename(String title) => state = '$id: $title';
}
手書きの family と違い、コード生成では引数を build() に書きます。生成されたクラスには同名のゲッター id が作られ、メソッドからも読めます。生成されたプロバイダは既定で自動破棄が有効(isAutoDispose が true)で、手書きの既定と逆になる点に注意してください。build_runner の版の制約やエラーの切り分けは、Riverpod 3系の導入ガイドのコード生成の章にまとめています。
移行を急がなくてよい場面と先に移すべきクラス
legacy の StateNotifier は 3.4.3 でも動き、警告も出ません。全クラスを一度に書き換える必要はなく、次の条件に当たるものから移します。
- await のあとに
mountedを見ているクラス:作り直しの扱いを前の章の書き方で整理する必要があるため、手を入れる価値が最も大きい部分です。 - プロバイダ定義で
ref.watchしている StateNotifier:依存が変わるたびにインスタンスごと作り直されていたので、Notifier に移すとフィールドの扱いが変わります。 - 永続化(experimental の persist)を使いたいクラス:persist は Notifier 系の拡張なので StateNotifier からは呼べません。
逆に、テストが無く == を実装した状態クラスで再通知に頼っている StateNotifier は、先に通知回数を確かめるテストを書いてから移します。前に書いたとおり判定が == に変わると、同じ内容の再代入では画面が更新されなくなるためです。複数画面にまたがる状態設計から見直す場合は、Flutter・React Nativeのアプリ開発支援で既存アプリの改修も扱っています。
よくある質問
NotifierProviderとは何ですか?
Notifier を継承したクラスの状態を画面に公開する Riverpod のプロバイダです。Riverpod 2.0 で導入され、3.0 からは同期的な状態を持つときの標準です。初期値は build() の戻り値で、変更は state への代入で行います。
StateNotifierProviderはもう使えないのですか?
使えます。Riverpod 3.4.3 でも削除されておらず、package:flutter_riverpod/legacy.dart を import すれば動きます。@Deprecated の警告も出ません。新しく書くコードでは NotifierProvider を選びます。
NotifierとAsyncNotifierの違いは何ですか?
Notifier は build() が値をそのまま返し、AsyncNotifier は同期値か Future を返し、state が AsyncValue になります。読み込み中やエラーの表示が要る状態は AsyncNotifier を選びます。
StateProviderの代わりは何を使えばよいですか?
Notifier と NotifierProvider です。StateProvider も 3.0 で legacy になりました。値を1つ持つだけなら、build() で初期値を返し、void set(int value) => state = value; のようなメソッドを1つ持たせれば足ります。
NotifierProviderに引数を渡すにはどうしますか?
NotifierProvider.family<Notifierの型, 状態の型, 引数の型> を使い、引数は Notifier のコンストラクタで受けます。3.0 で FamilyNotifier は削除されました。コード生成の場合は build() の引数に書きます。