状態を更新する側と作り直す仕組み
Notifier と AsyncNotifier が状態を更新する流れから、provider 同士の依存、invalidate と refresh、自動破棄、family、Riverpod 3 の変更点までを一つのライフサイクルとして整理する。
前回は、Riverpod の provider を「値を公開するもの」と「状態を更新する側を持つもの」に分けました。
後者の中心にいるのが Notifier と AsyncNotifier です。ただし、状態を更新する方法だけを
覚えても、その状態がいつ作られ、いつ捨てられるかまでは見えてきません。
provider は、依存先が変われば作り直されます。明示的に再計算でき、誰にも読まれなくなった状態を 破棄できます。今回は更新する側と作り直す仕組みを、一つのライフサイクルとして捉えます。
§ 01NOTIFIERbuild() が初期値、state が現在値
Notifier<T> は、同期的な状態と、その状態を変える操作を一つのクラスにまとめます。カウンターなら、
build() が初期値を返し、メソッドが state を書き換えます。
final counterProvider = NotifierProvider<Counter, int>(Counter.new);class Counter extends Notifier<int> {@overrideint build() => 0;void increment() {state = state + 1;}void reset() {state = 0;}}
UI は ref.watch(counterProvider) で int を購読します。状態を変える場面では Notifier を
read し、たとえば ref.read(counterProvider.notifier).increment() を呼びます。このメソッドが
state を書き換え、その代入が新しい値の通知となって購読側を更新します。
ここで build() は Widget の build() ではなく、provider の状態を初期化する関数です。最初に
読まれたときだけでなく、依存の変化や明示的な再計算でも実行されますが、state を普通に
書き換えても build() へ戻りません。つまり、初期化・再計算と状態の更新は別の経路です。
§ 02ASYNC待ち時間ごと状態に含める
AsyncNotifier<T> は同じ役割を非同期状態に広げたものです。build() の戻り型は
FutureOr<T>、外から見える状態は AsyncValue<T> になります。初期値を同期的に返しても、
Future<T> を返して初期化を待っても構いません。
final messagesProvider =AsyncNotifierProvider<Messages, List<Message>>(Messages.new);class Messages extends AsyncNotifier<List<Message>> {@overrideFutureOr<List<Message>> build() => fetchMessages();Future<void> add(Message message) async {state = const AsyncValue.loading();state = await AsyncValue.guard(() async {await saveMessage(message);return fetchMessages();});}}
add() は処理の開始時に loading を通知し、完了後に新しい一覧を data として通知します。
AsyncValue.guard は Future の成功を AsyncData、失敗を AsyncError に変え、例外を
握りつぶさずに状態へ戻します。独自に try/catch を書くなら、失敗時にも state の更新が必要です。
取得だけの FutureProvider と違い、AsyncNotifier は add や save のような更新メソッドを
持てるのが要点です。UI は結果の AsyncValue を描画し、更新手順は Notifier 側に置きます。
§ 03DEPENDENCYwatch が作り直す線を引く
provider の中で別の provider を ref.watch すると、その呼び出しが依存関係になります。次の例では、
選択中のカテゴリが変わるたびに itemsProvider の処理がもう一度走ります。
final categoryProvider =NotifierProvider<Category, String>(Category.new);final itemsProvider = FutureProvider<List<Item>>((ref) async {final category = ref.watch(categoryProvider);return fetchItems(category);});
categoryProvider の state が変わると、古い itemsProvider の状態は破棄され、新しいカテゴリで
再計算されます。つまり watch は値を読むだけでなく、どの変化で自分を作り直すかを宣言して
います。依存を持たない provider まで一括でリセットする必要はありません。
この再計算は、次に見る自動破棄とは別です。依存先が変われば、購読者がいる provider も作り直され、
自動破棄を無効にしていても古い状態は破棄されます1自動破棄は購読者がいなくなった状態を捨てる設定だが、依存変更や invalidate による再計算では設定にかかわらず古い状態が破棄され、新しい状態が作られる(Riverpod “Automatic disposal”)。。
§ 04RECOMPUTEinvalidate と refresh を使い分ける
依存先の変化を待たず、明示的に provider を作り直す入口が invalidate と refresh です。二つの
関係は単純で、refresh は invalidate の直後に read する糖衣です。
Future<void> reload(WidgetRef ref) async {await ref.refresh(itemsProvider.future);}void clearCache(WidgetRef ref) {ref.invalidate(itemsProvider);}
refresh はその場で再計算し、新しい値を返します。pull-to-refresh の完了を待つなど、呼び出し側が
結果を必要とするならこちらです。invalidate は値を返さない投げっぱなしの操作です。キャッシュを
無効にし、後続の購読へ任せる場面に向きます。
購読中の provider を invalidate すると、一般には次のフレームの先頭で再計算されます。誰も購読
していなければ、その時点では再計算せず、次に購読されたときに初めて作られます。不要な値を
「今すぐ取り直す」操作ではなく、現在の状態を期限切れにする操作だと捉えると区別できます。
非同期 provider が一度値を出したあとで明示的に再計算されると、AsyncValue は loading に
入っても前回の値を value に保持します。この状態は isRefreshing で判別でき、依存先の変化に
よる再計算は isReloading で判別できます。
.when は skipLoadingOnRefresh: true が既定なので、refresh 中は前回の data 表示を保ちます。
一方、skipLoadingOnReload は既定で false のため、依存変更では loading 表示へ切り替わります。
常にスピナーを出したければ前者を false にし、reload 中も古い表示を保ちたければ後者を true に
します。Riverpod 3 では旧 valueOrNull が value に統合され、値がなければ null を返します。
§ 05LIFETIME読まれなくなった状態を手放す
再計算とは別に、provider は「誰にも読まれていない」ことを理由に破棄できます。codegen を使わない
宣言では自動破棄は opt-in で、現行 API は isAutoDispose: true です。
final itemProvider = Provider<Item>((ref) => loadItem(),isAutoDispose: true,);
最後の購読者が外れると ref.onCancel が呼ばれ、丸一フレーム購読が戻らなければ状態が破棄されます。
その間に購読が戻ると ref.onResume が呼ばれます。接続解除や controller の終了処理など、破棄時に
必要な後始末は ref.onDispose に登録します。
成功した通信結果だけ少し残したい、といった場合は ref.keepAlive() を呼びます。戻り値の link を
保持して link.close() を呼べば、その時点から再び自動破棄の対象になります。keepAlive は永続
保存ではなく、現在の provider 状態をメモリ上に保つためのライフサイクル制御です。
古い記事では Provider.autoDispose<Item>((ref) => ...) のような .autoDispose 修飾子も
見かけます。この修飾子は現在も使えます。「AutoDisposeProvider のような型は Riverpod 3 で
消えた」ことと、「auto-dispose 機能が消えた」ことは別です。なお codegen では自動破棄が既定で、
@Riverpod(keepAlive: true) によって無効にします。
§ 06FAMILY引数ごとに状態を分ける
family は、外から引数を受け取る provider です。同じ宣言から、商品 ID ごとのカウンターのように
引数で区別された状態を作れます。次の A と B も、それぞれ独立したカウンターになります。
final itemCounterProvider =NotifierProvider.family<ItemCounter, int, String>(ItemCounter.new);class ItemCounter extends Notifier<int> {ItemCounter(this.itemId);final String itemId;@overrideint build() => 0;void increment() {state = state + 1;}}
ref.watch(itemCounterProvider('A')) と itemCounterProvider('B') は別の状態です。引数の組み合わせ
ごとに状態が増えるため、使い終えた組み合わせを残す理由がなければ自動破棄を有効にします。また、
引数には安定した == と hashCode を持つ値を使います。
Riverpod 2 以前の記事では FamilyNotifier や FamilyAsyncNotifier を継承する例があります。
非 codegen の Notifier family では、provider 宣言の .family は残し、専用の Notifier 型だけを
Notifier へ置き換えます。引数は build(arg) ではなく、上のようにコンストラクタで受け取ります。
§ 07RIVERPOD-3型が減り、実行時の挙動が増えた
ここまでに出てきた Riverpod 3 の変更を、古いコードを読むための変換表としてまとめます。
| Riverpod 2 以前で見かける形 | Riverpod 3 で読む形 |
|---|---|
AutoDisposeProvider | Provider + 自動破棄の設定 |
AutoDisposeNotifier | Notifier + 自動破棄の設定 |
FamilyNotifier | 引数をコンストラクタで受ける Notifier |
FamilyAsyncNotifier | 引数をコンストラクタで受ける AsyncNotifier |
AsyncValue.valueOrNull | AsyncValue.value |
AutoDispose* 型は基本型へ統合され、移行ガイドでは型名から AutoDispose を取り除きます。
family の Notifier 変種も基本型へ統合されました。どちらも機能を残し、型の重複を減らした変更です。
もう一つ、Riverpod 3 では provider の初期化が失敗すると自動で再試行されます。既定では 200ms から
最大 6.4 秒まで間隔を延ばす指数バックオフです。Provider などが持つ retry パラメータで、
provider ごとに待ち時間を変えたり、null を返して再試行を止めたりできます2Riverpod 3 の既定 retry は失敗した provider 初期化を 200ms から最大 6.4 秒の指数バックオフで再試行する。retry が null を返すと再試行を終了する(What's new in Riverpod 3.0)。。
これは AsyncNotifier の更新メソッドを自動で再実行する機能ではありません。対象は provider の
初期化です。保存ボタンの処理まで勝手に再送される、と読み違えないようにします。
§ 08SUMMARY更新と再計算を分けて考える
Notifierはbuild()で同期状態を初期化し、stateへの代入で更新を通知するAsyncNotifierのbuild()はFutureOr<T>、外から見える状態はAsyncValue<T>- provider 内の
ref.watchは依存関係を作り、依存先の変化で状態を再計算する refreshはinvalidate+read。新しい値が要るなら前者、無効化だけなら後者を使う- refresh 中の
AsyncValueは前回値を保持し、.whenは既定でその表示を残す - 非 codegen の自動破棄は
isAutoDispose: trueで有効にし、keepAliveで一時的に保持できる - family は引数ごとに状態を分ける。Riverpod 3 では専用の Notifier 型を使わない
- Riverpod 3 は型を統合し、失敗した provider 初期化の自動 retry を追加した
状態を書き換えること、依存の変化で作り直すこと、読まれなくなって破棄することは、それぞれ別の
入口です。この区別ができると「なぜもう一度 build() が走ったのか」をコードから追えます。
次回は状態管理から一歩引き、依存性注入そのものが何を解決する設計なのかを整理します。
- [1] 自動破棄は購読者がいなくなった状態を捨てる設定だが、依存変更や
invalidateによる再計算では設定にかかわらず古い状態が破棄され、新しい状態が作られる(Riverpod “Automatic disposal”)。 ↩ - [2] Riverpod 3 の既定 retry は失敗した provider 初期化を 200ms から最大 6.4 秒の指数バックオフで再試行する。
retryがnullを返すと再試行を終了する(What's new in Riverpod 3.0)。 ↩