[読書会]Riverpod.dev/docs 翻訳及び補足(Migration guides From StateNotifier)

Riverpod.dev/docs(開始URL: https://riverpod.dev/docs/introduction/why_riverpod )には日本語訳ページ(/ja)も部分的には既にあるのですが、その部分はそのまま参照して、その他の英語訳のままのページについては自力で翻訳および解説を補足してみました。まだ工事中ですが最終的に満足ができたら上記サイトの翻訳ページに献上できないかな等という目標もあります。

本ブログの(上記Riverpod.dev/docsの)翻訳および解説の目次頁(構成上のトップページ)は次のURLになります。

本頁は、上記のうちの、「Migration guides / From ChangeNotifier」の章の翻訳および解説頁になります。

Migration guides

移行ガイド

From ChangeNotifier

Riverpod 内では、ChangeNotifierProvider は pkg:provider からのスムーズな移行を提供するために使用されます。

pkg:riverpod への移行を開始したばかりの場合は、必ず専用のガイドをお読みください (クイックスタートを参照)。この記事は、すでに Riverpod に移行しているが、ChangeNotifier から明確に離れたいと考えている人を対象としています。

全体として、ChangeNotifier から AsyncNotifer への移行にはパラダイム シフトが必要ですが、移行後のコードは大幅に簡素化されます。 「なぜ不変なのか」も参照してください。

次の(誤ったコード)事例を考えてみましょう。

(誤ったコード)
class MyChangeNotifier extends ChangeNotifier {
  MyChangeNotifier() {
    _init();
  }
  List<Todo> todos = [];
  bool isLoading = true;
  bool hasError = false;

  Future<void> _init() async {
    try {
      final json = await http.get('api/todos');
      todos = [...json.map(Todo.fromJson)];
    } on Exception {
      hasError = true;
    } finally {
      isLoading = false;
      notifyListeners();
    }
  }

  Future<void> addTodo(int id) async {
    isLoading = true;
    notifyListeners();

    try {
      final json = await http.post('api/todos');
      todos = [...json.map(Todo.fromJson)];
      hasError = false;
    } on Exception {
      hasError = true;
    } finally {
      isLoading = false;
      notifyListeners();
    }
  }
}

final myChangeProvider = ChangeNotifierProvider<MyChangeNotifier>((ref) {
  return MyChangeNotifier();
});

上記コードには、従来のChangeNotifierを使った実装における、設計上の弱点や問題点が含まれています。

  • 複数の非同期処理のハンドリングにおける、isLoadinghasError使用法について
  • 冗長になりがちな try / catch / finally ブロックを適切に使用する必要性について
  • notifyListeners適切なタイミングで呼び出す必要性について
  • 一貫性のない状態や、望ましくない初期状態(例:空リストによる初期化

初心者開発者が、如何にして、ChangeNotifier を使うことで、誤った設計を選択するに至ったのか、上記コードは示しています。
また、もう 1 つのポイントは、可変状態は思っている程には簡単にはいかない、ということです。

Notifier/AsyncNotiferを不変の状態と組み合わせると、より適切な設計の選択が可能になり、エラーが減ります。

上記のスニペットを最新の API に 1 ステップずつ移行する方法を見てみましょう。

移行の開始

まず、新しいプロバイダー/通知者を宣言する必要があります。
これには、独自のビジネス ロジックに依存するいくつかの思考プロセスが必要です。

上記の要件をまとめてみましょう。

  • 状態は、パラメータなしでネットワーク呼び出し経由で取得される List<Todo> で表されます。
  • 状態は、読み込み、エラー、データの状態に関する情報も公開する必要があります。
  • 公開されたメソッドを介して状態を変更できるため、関数だけでは十分ではありません。

TIP)
上記の思考プロセスは、要約すると次の質問に答えることになります。

1.必要な副作用は必要か?
 ある→ Riverpod のクラスベース API を使用する。
 ない→ Riverpodの関数型APIを使う。

2.状態は非同期にロードする必要があるのか?
 ある→ build は Future<T> を返す。
 ない→ build は 単純に T を返す。

3.必要なパラメータはありますか?
 ある→ build (または機能)に受け入れさせる。
 ない→ build (または関数)に余計なパラメータを受け取らせない。

INFO)
コード生成(code generation)を使うのであれば、上記の思考プロセスで十分だ。 適切なクラス名や特定のAPIについて考える必要はない。@riverpodは、戻り値の型を持つクラスを書くよう求めるだけで、それでOKだ。

(コード生成(code generation)使用)@riverpodは、戻り値の型を持つクラスを書くよう求めるだけで、それでOKだ。:
@riverpod
class Counter extends _$Counter {
  @override
  int build() => 0;
}

技術的には、ここで最も適切なのは、上記の要件をすべて満たす AutoDisposeAsyncNotifier<List<Todo>> を定義することです。まず疑似コードを書いてみましょう。

(疑似コード(ステップ1))※以降のステップ2~3で完成します。:
@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  FutureOr<List<Todo>> build() {
    // TODO ...
    return [];
  }

  Future<void> addTodo(Todo todo) async {
    // TODO
  }
}

TIP)
IDEでスニペットを使うことで、ガイダンスを得たり、コードを書くスピードを上げたりすることができます。 入門を参照してください。

ChangeNotifier の実装に関しては、todo を宣言する必要はもうありません。このような変数は 状態であり、ビルド時に暗黙的にロードされます。
実際、riverpod のnotifiers は一度に 1 つのエンティティを公開できます。

TIP)
Riverpod の API は粒度が細かいことを目的としています。ただし、移行する場合でも、複数の値を保持するカスタム エンティティを定義できます。最初は移行をスムーズにするために Dart 3 のrecordを使用することを検討してください。

初期設定

notifier を初期化するのは簡単で、buildの中に初期化ロジックを書くだけだ。
これで古い_init関数を取り除くことができる。

(疑似コード(ステップ2))※以降のステップ3で完成します。:
@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  FutureOr<List<Todo>> build() async {
    final json = await http.get('api/todos');
    return [...json.map(Todo.fromJson)];
  }
}

isLoadinghasError変数を初期化する必要がなくなったのだ。

Riverpodは、AsyncValue>を公開することで、あらゆる非同期プロバイダを自動的に変換し、2つの単純なブーリアンフラグよりもはるかに優れた非同期状態の複雑さを処理します。

実際、AsyncNotifierを使えば、非同期状態を処理するためにtry/catch/finallyを追加で書くことは、事実上アンチパターンになる。

Mutations and Side Effects

初期化と同じように、副作用を実行するときにhasErrorのようなboolean フラグを操作したり、try/catch/finallyブロックを追加で書いたりする必要はない。

以下では、すべての定型文を削減し、上記の例を完全に移行することに成功した。

(疑似コード(ステップ3))※完成しました。:
@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  FutureOr<List<Todo>> build() async {
    final json = await http.get('api/todos');

    return [...json.map(Todo.fromJson)];
  }

  Future<void> addTodo(Todo todo) async {
    // optional: state = const AsyncLoading();
    final json = await http.post('api/todos');
    final newTodos = [...json.map(Todo.fromJson)];
    state = AsyncData(newTodos);
  }
}

TIP)
構文や設計の選択は様々かもしれないが、最終的にはリクエストを書き、その後ステートを更新するだけでよい。 副作用の実行を参照してください。

補足)
FutureOr は、Dartプログラミング言語で使われる型で、Future または Tどちらかを受け取ることができる柔軟な型です。簡単に言うと、ある処理が非同期結果Future)を返す可能性もあれば、同期的な結果通常の値)を返す可能性もある場合に、この FutureOr を使います。

Dart
FutureOr<int> exampleFunction(bool asyncMode) {
  if (asyncMode) {
    return Future.delayed(Duration(seconds: 1), () => 42); // 非同期処理
  } else {
    return 42; // 同期処理
  }
}

この例では、exampleFunction は引数に応じて、同期的にintを返すか、非同期でFutureを返すかを決めます。FutureOrを使うことで、どちらの場合も対応できます。

使われる場面
・非同期処理と同期処理を同じインターフェースで扱いたい場合。
・APIが同期か非同期かに関係なく動作する場合。
FutureOr は、特に柔軟な設計が求められる場面で有用です。たとえば、APIの実装で、非同期でデータを取得するか、キャッシュから同期的に取得するかを選べる場合に適しています。

移行プロセスのまとめ

上記で適用した移行プロセス全体を、運用の観点から見直してみよう。

1.初期化は(コンストラクタで呼び出すカスタムメソッド(_init()))からbuildに移しました。
2.todos、isLoading、hasErrorプロパティは削除しました。
3.try-catch-finallyブロックは全て削除しました。
4.副作用(addTodo)についても同じ単純化を適用しました。
5.(state と AsyncData()による)状態変更を適用しました。

コメントを残す