🚨

Performing side effects(副作用の実行)

2024/02/22に公開

対象者

  • riverpod2.0を使ったことある人
  • HTTP通信の知識がある人

やること/やらないこと

やること:
こちらのriverpod公式の話題が気になってたので、やってみた!
https://riverpod.dev/docs/essentials/side_effects#using-refinvalidateself-to-refresh-the-provider

HTTP POSTメソッドを使うのですが、送信するサーバーがないのでnpmを使ってmock-severを構築して、そちらにデータをPOSTします。

こちらの本を参考に構築して下さい。
https://zenn.dev/joo_hashi/books/20dd2274ba88a9/viewer/5514b7

db.jsonを作成したら、最初は空っぽのtodoという配列があるJSONを作って下さい。空っぽの入れ物がないと、POSTできないです。

{
  "todo": [
    
  ]
}

mock-serverを起動するときは、このコマンドを使う

json-server --watch db.json

mock-serverのエンドポイントはこちら
http://localhost:3000/todo

やらないこと:
詳しく踏み込んでいこうとは、思わないです。httpsと書かれていたコードがありましたが、localhostにhttpsでPOSTしようとすると、サーバーがSSL/TLS証明書を提供していないか、またはクライアントがその証明書を信頼できない場合にエラーが発生する現象が起きるので、私は、https -> httpに修正して実験してます。

プロジェクトの説明

riverpodとfreezedが必要なので、パッケージをインストールします。まとめてインストールできる便利なコマンドを作ったので、よかったら使ってみてください。API通信するので、httpパッケージも追加してください。注意点がありまして、Flutterは最新版ではないと、このコマンドが使えないかもしれません。

https://zenn.dev/jboy_blog/articles/9e16c80f18911c
https://zenn.dev/jboy_blog/articles/9c6bdebf87b8ae
https://pub.dev/packages/http

レイヤーはこのように分けてください。フォルダを分けてくださいって表現の方が伝わりやすいですかね。

freezedでAPIで使うモデルを定義する

モデル
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'todo.freezed.dart';
part 'todo.g.dart';

// 公式の引数に合わせてモデルを定義

class Todo with _$Todo {
  const factory Todo({
    ('') String description,
    (false) bool completed,
  }) = _Todo;

  factory Todo.fromJson(Map<String, Object?> json)
      => _$TodoFromJson(json);
}

HTTP GETリクエストを送って、mock-serverからAPIのデータを取得するプロバイダーを作成。公式のだと使えそうにないので、アレンジしてます。

FutureProvider
import 'dart:convert';

import 'package:performin_side_effects/domain/todo.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:http/http.dart' as http;
part 'get_todo_list.g.dart';

// HTTP GETリクエストを送信する。todoというListを持っているJSONから
// POSTしたデータを取得する

Future<List<Todo>> getTodoList(GetTodoListRef ref) async {
  try {
    final response = await http.get(
      Uri.http('localhost:3000', '/todo'),
    );

    if (response.statusCode == 200) {
      final List<dynamic> todoList = jsonDecode(response.body);
      return todoList.map((e) => Todo.fromJson(e)).toList();
    } else {
      throw Exception('Failed to load todo list');
    }
  } on Exception catch (e) {
    throw Exception('api call error: $e');
  }
}

公式に書いてあるコードをhttpsだと使えないものは、httpに変更して使ってます。好きなの選んでPOSTしてみください。

AsyncNotifier
import 'dart:convert';

import 'package:performin_side_effects/domain/todo.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:http/http.dart' as http;
part 'todo_list.g.dart';


class TodoList extends _$TodoList {
  
  Future<List<Todo>> build() async => [];

  // Future<void> addTodo(Todo todo) async {
  //   await http.post(
  //     Uri.http('localhost:3000', '/todo'),
  //     headers: {'Content-Type': 'application/json'},
  //     body: jsonEncode(todo.toJson()),
  //   );
  // }

  // Future<void> addTodo(Todo todo) async {
  //   // The POST request will return a List<Todo> matching the new application state
  //   final response = await http.post(
  //     // (localhost:3000)は通常、SSL/TLS証明書を提供していないため、
  //     // https -> httpに修正
  //     Uri.http('localhost:3000', '/todo'),
  //     headers: {'Content-Type': 'application/json'},
  //     body: jsonEncode(todo.toJson()),
  //   );

  //   Map<String, dynamic> todoMap = jsonDecode(response.body);
  //   Todo newTodos = Todo.fromJson(todoMap);

  //   // newTodosは単一のインスタンスであるためリストで包む必要がある
  //   state = AsyncData([newTodos]);
  // }

    Future<void> addTodo(Todo todo) async {
    // We don't care about the API response
    await http.post(
      Uri.http('localhost:3000', '/todo'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );

    // We can then manually update the local cache. For this, we'll need to
    // obtain the previous state.
    // Caution: The previous state may still be loading or in error state.
    // A graceful way of handling this would be to read `this.future` instead
    // of `this.state`, which would enable awaiting the loading state, and
    // throw an error if the state is in error state.
    final previousState = await future;

    // We can then update the state, by creating a new state object.
    // This will notify all listeners.
    state = AsyncData([...previousState, todo]);
  }
}

mock-serverにデータをPOSTするボタンを押すと、AsyncNotifierのハードコーディングされたロジックが実行されて、データが保存されます。POSTした後に、ref.invalidateを実行してAPIのデータを更新して、プロバイダーを更新して画面に増えたデータが表示されます。

View側のコード
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:performin_side_effects/application/todo_list.dart';
import 'package:performin_side_effects/domain/todo.dart';
import 'package:performin_side_effects/infra/get_todo_list.dart';

class Example extends ConsumerWidget {
  const Example({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
        appBar: AppBar(
          title: const Text('Example'),
        ),
        body: Center(
          child: Column(
            children: [
              ElevatedButton(
                onPressed: () {
                  // Using "ref.read" combined with "myProvider.notifier", we can
                  // obtain the class instance of our notifier. This enables us
                  // to call the "addTodo" method.
                  ref
                      .read(todoListProvider.notifier)
                      .addTodo(const Todo(description: 'This is a new todo'));
                      
                  // ここで、getTodoListProviderをinvalidateすることで、再度データを取得する
                  ref.invalidate(getTodoListProvider);
                },
                child: const Text('Add todo'),
              ),
              // Expandedで、wrapしないと表示できない!
              const Expanded(child: GetTodoListView()),
            ],
          ),
        ));
  }
}

// mockのデータを表示するコンポーネント
class GetTodoListView extends ConsumerWidget {
  const GetTodoListView({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final todoList = ref.watch(getTodoListProvider);
    // riverpod2.0からは、switch文を使うらしい
    return switch (todoList) {
      AsyncData(:final value) => ListView.builder(
          itemCount: value.length,
          itemBuilder: (context, index) {
            final todo = value[index];
            return ListTile(
              title: Text(todo.description),
              trailing: Checkbox(
                value: todo.completed,
                onChanged: (newValue) {
                  // 今回はロジック書かない
                },
              ),
            );
          },
        ),
      AsyncError(:final error) => Text('Error: $error'),
      _ => const Text('ロード中...'),
    };
  }
}

// class Example extends ConsumerStatefulWidget {
//   const Example({super.key});

//   @override
//   ConsumerState<ConsumerStatefulWidget> createState() => _ExampleState();
// }

// class _ExampleState extends ConsumerState<Example> {
//   // The pending addTodo operation. Or null if none is pending.
//   Future<void>? _pendingAddTodo;

//   @override
//   Widget build(BuildContext context) {
//     return Scaffold(
//       appBar: AppBar(
//         title: const Text('Example'),
//       ),
//       body: FutureBuilder(
//       // We listen to the pending operation, to update the UI accordingly.
//       future: _pendingAddTodo,
//       builder: (context, snapshot) {
//         // Compute whether there is an error state or not.
//         // The connectionState check is here to handle when the operation is retried.
//         final isErrored = snapshot.hasError &&
//             snapshot.connectionState != ConnectionState.waiting;

//         return Row(
//           mainAxisAlignment: MainAxisAlignment.center,
//           children: [
//             ElevatedButton(
//               style: ButtonStyle(
//                 // If there is an error, we show the button in red
//                 backgroundColor: MaterialStateProperty.all(
//                   isErrored ? Colors.red : null,
//                 ),
//               ),
//               onPressed: () {
//                 // We keep the future returned by addTodo in a variable
//                 final future = ref
//                     .read(todoListProvider.notifier)
//                     .addTodo(const Todo(description: 'This is a new todo'));

//                 // We store that future in the local state
//                 setState(() {
//                   _pendingAddTodo = future;
//                 });
//               },
//               child: const Text('Add todo'),
//             ),
//             // The operation is pending, let's show a progress indicator
//             if (snapshot.connectionState == ConnectionState.waiting) ...[
//               const SizedBox(width: 8),
//               const CircularProgressIndicator(),
//             ]
//           ],
//         );
//       },
//     ),
//     );
//   }
// }

実行するときは、main.dartでimportして使ってください。

実行するコード
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:performin_side_effects/presentation/example.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const Example(),
    );
  }
}

mock-serverを起動しておいて下さい!
ボタンを押すと、mock-serverにデータがどんどん追加せれていきます!

APIのデータがGETできてればこのように表示されます。

ここからは公式を翻訳しただけ。全部は解説しない。使ってない部分もあるので。

副作用の実行

ここまでは、データをフェッチする方法 (つまり、GET HTTP リクエストを実行する方法) のみを見てきました。しかし、 POST
リクエストなどの副作用についてはどうでしょうか?

アプリケーションは多くの場合、CRUD (作成、読み取り、更新、削除) API を実装します。
その際、UI に新しい状態を反映させるために、更新リクエスト (通常はPOST ) でローカル キャッシュも更新する必要があるのが一般的です。

問題は、コンシューマ内からプロバイダの状態をどのように更新するかということです。
当然のことながら、プロバイダーはその状態を変更する方法を公開していません。これは、制御された方法でのみ状態が変更されるようにし、懸念事項の分離を促進するための設計によるものです。
代わりに、プロバイダーは状態を変更する方法を明示的に公開する必要があります。

これを行うために、Notifier という新しい概念を使用します。
この新しい概念を紹介するために、より高度な例、To-Do リストを使用してみましょう。

Todo のリストを取得したので、新しい Todo を追加する方法を見てみましょう。
このためには、状態を変更するためのパブリック API を公開するようにプロバイダーを変更する必要があります。これは、プロバイダーを「ノーティファイア」と呼ばれるものに変換することによって行われます。

Notifier はプロバイダーの「ステートフル ウィジェット」です。プロバイダーを定義するための構文を少し調整する必要があります。
この新しい構文は次のとおりです。

クラス MyNotifier extends _$MyNotifier {
   

  結果 build() {
    <ここにあなたのロジック>
  }
  <ここでのメソッド>
}

すべてのプロバイダーには、@riverpodまたは の注釈が付けられている必要があります@Riverpod()。このアノテーションはグローバル関数またはクラスに配置できます。
このアノテーションを通じて、プロバイダーを構成できます。

たとえば、 と記述することで「自動破棄」 (後で説明します) を無効にすることができます@Riverpod(keepAlive: true)。

@riverpodアノテーションがクラスに配置されると、そのクラスは「Notifier」と呼ばれます。
クラスは を拡張する必要があります_$NotifierName。 はNotifierNameクラス名です。

Notifier は、プロバイダーの状態を変更する方法を公開する責任があります。
このクラスのパブリック メソッドは、コンシューマが を使用してアクセスできますref.read(yourProvider.notifier).yourMethod()。

すべての通知者はbuildメソッドをオーバーライドする必要があります。
このメソッドは、通常、非通知プロバイダーにロジックを配置する場所と同等です。

このメソッドは直接呼び出すべきではありません。

POSTを実行するメソッドを公開する

Notifier が完成したので、副作用の実行を可能にするメソッドの追加を開始できます。そのような副作用の 1 つは、クライアントに新しい ToDoを POST させることです。addTodoこれは、ノーティファイアにメソッドを追加することで実現できます。

Future<void> addTodo(Todo todo) async {
    await http.post(
      Uri.http('localhost:3000', '/todo'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );
  }

次に、 「最初のプロバイダー/ネットワーク要求を行う」 で見たのと同じConsumer/を使用して、UI でこのメソッドを呼び出すことができます。ConsumerWidget

ここは、Exampleクラスのボタンを押すファイルの解説です。

これで、押されたときにPOSTリクエストを行うボタンができました。
ただし、現時点では、UI は新しい ToDo リストを反映するように更新されていません。ローカル キャッシュをサーバーの状態と一致させる必要があります。

これを行う方法はいくつかありますが、長所と短所があります。

API
一般的なバックエンドの実践では、POSTリクエストでリソースの新しい状態を返すようにします。
特に、私たちの API は、新しい Todo を追加した後、新しい Todo リストを返します。これを行う 1 つの方法は、次のように記述することですstate = AsyncData(response)。

こんな感じで修正しないと使えなかった😇

Future<void> addTodo(Todo todo) async {
    // The POST request will return a List<Todo> matching the new application state
    final response = await http.post(
      // (localhost:3000)は通常、SSL/TLS証明書を提供していないため、
      // https -> httpに修正
      Uri.http('localhost:3000', '/todo'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );

    Map<String, dynamic> todoMap = jsonDecode(response.body);
    Todo newTodos = Todo.fromJson(todoMap);

    // newTodosは単一のインスタンスであるためリストで包む必要がある
    state = AsyncData([newTodos]);
  }

💡長所

  • UI は可能な限り最新の状態になります。別のユーザーが Todo を追加した場合、それも表示されます。
  • サーバーは真実の情報源です。このアプローチでは、クライアントは新しい Todo を Todo リストのどこに挿入する必要があるかを知る必要がありません。
  • 必要なネットワーク要求は 1 つだけです。

🔥短所

  • このアプローチは、サーバーが特定の方法で実装されている場合にのみ機能します。サーバーが新しい状態を返さない場合、このアプローチは機能しません。
  • 関連付けられたGETリクエストがより複雑な場合 (フィルタ/並べ替えがある場合など)、依然として実行できない可能性があります。

ref.invalidateSelf()プロバイダーを更新するため使用します。
1 つのオプションは、プロバイダーにGETリクエストを再実行させることです。これは、 POSTリクエストの後に
呼び出して行うことができます。ref.invalidateSelf()

実際のところPOSTしても更新してくれないような...
画面は切り替わらなかった???
ref.invalidateをコメントアウトして使ってみたが、代わり使えなかったようだ???

Future<void> addTodo(Todo todo) async {
    // We don't care about the API response
    await http.post(
      Uri.http('localhost:3000', '/todo'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );

    // ポストリクエストが完了したら、ローカルキャッシュをダーティとしてマークすることができる。
    // これにより、ノーティファイアの "build "が非同期で再度呼び出されます、
    // その際、リスナーに通知されます。
    ref.invalidateSelf();

    // オプション)その後、新しい状態が計算されるのを待つことができる。
    // これにより、新しい状態が利用可能になるまで // "addTodo "が完了しないようにします。
    await future;
  }

電気長所

  • UI は可能な限り最新の状態になります。別のユーザーが Todo を追加した場合、それも表示されます。
  • サーバーは真実の情報源です。このアプローチでは、クライアントは新しい Todo を Todo リストのどこに挿入する必要があるかを知る必要がありません。
  • このアプローチは、サーバーの実装に関係なく機能するはずです。これは、フィルターや並べ替えが含まれる場合など、GETリクエストがより複雑な場合に特に便利です。

🔥短所

このアプローチでは追加のGETリクエストが実行されるため、非効率となる可能性があります。

ローカル キャッシュを手動で

もう 1 つのオプションは、ローカル キャッシュを手動で更新することです。
これには、バックエンドの動作を模倣する試みが含まれます。たとえば、バックエンドが新しい項目を最初に挿入するのか、最後に挿入するのかを知る必要があります。

Future<void> addTodo(Todo todo) async {
    // We don't care about the API response
    await http.post(
      Uri.http('localhost:3000', '/todo'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(todo.toJson()),
    );

    // その後、手動でローカルキャッシュを更新することができる。そのためには
    // 以前の状態を取得する。
    // 注意: 前の状態はまだロード中かエラー状態かもしれない。
    // これを処理する優雅な方法は、 `this.state` の代わりに `this.future` を読み込むことである。
    // の代わりに `this.future` を読み込むことである。
    // エラー状態の場合はエラーを投げる。
    final previousState = await future;

    // 新しい状態オブジェクトを作成することで、状態を更新できる。
    // これはすべてのリスナーに通知されます。
    state = AsyncData([...previousState, todo]);
  }

これは使うのだろうか???

💡長所

  • このアプローチは、サーバーの実装に関係なく機能するはずです。
  • 必要なネットワーク要求は 1 つだけです。

🔥短所

  • ローカル キャッシュがサーバーの状態と一致しない可能性があります。別のユーザーが Todo を追加した場合、それは表示されません。
  • このアプローチは、バックエンドのロジックを実装して効果的に複製するのがより複雑になる可能性があります。

さらに進む: スピナーとエラー
これまで見てきたように、押されるとPOSTリクエストを行うボタンができました。リクエストが完了すると、UI が更新されて変更が反映されます。
しかし現時点では、リクエストが実行されているという兆候も、失敗した場合の情報もありません。

そのための 1 つの方法は、addTodo ローカル ウィジェットの状態で返された Future を保存し、その Future をリッスンしてスピナーまたはエラー メッセージを表示することです。これは、 flutter_hooks が役立つ
1 つのシナリオです。もちろん、代わりに を使用することもできます。StatefulWidget

次のスニペットは、操作が保留中の間の進行状況インジケーターを示しています。失敗した場合は、ボタンが赤で表示されます。

これは動いてくれない時があったような...
おっと、mock-serverを停止してれば、ボタン赤色になった!

class Example extends ConsumerStatefulWidget {
  const Example({super.key});

  
  ConsumerState<ConsumerStatefulWidget> createState() => _ExampleState();
}

class _ExampleState extends ConsumerState<Example> {
  // The pending addTodo operation. Or null if none is pending.
  Future<void>? _pendingAddTodo;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Example'),
      ),
      body: FutureBuilder(
      // We listen to the pending operation, to update the UI accordingly.
      future: _pendingAddTodo,
      builder: (context, snapshot) {
        // Compute whether there is an error state or not.
        // The connectionState check is here to handle when the operation is retried.
        final isErrored = snapshot.hasError &&
            snapshot.connectionState != ConnectionState.waiting;

        return Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              style: ButtonStyle(
                // If there is an error, we show the button in red
                backgroundColor: MaterialStateProperty.all(
                  isErrored ? Colors.red : null,
                ),
              ),
              onPressed: () {
                // We keep the future returned by addTodo in a variable
                final future = ref
                    .read(todoListProvider.notifier)
                    .addTodo(const Todo(description: 'This is a new todo'));

                // We store that future in the local state
                setState(() {
                  _pendingAddTodo = future;
                });
              },
              child: const Text('Add todo'),
            ),
            // The operation is pending, let's show a progress indicator
            if (snapshot.connectionState == ConnectionState.waiting) ...[
              const SizedBox(width: 8),
              const CircularProgressIndicator(),
            ]
          ],
        );
      },
    ),
    );
  }
}

感想

今回は、riverpodを使用してネットワークと通信を行い副作用について学んでみました。

invalidateSelfってなんだったのか。内部実装を見てみた。

/// プロバイダーの状態を無効にし、リフレッシュさせる。
///
/// リフレッシュは即時ではなく、次の読み取りまたは次のフレームまで遅延される。
/// または次のフレームまで遅延される。
///
/// [invalidateSelf]を複数回呼び出すと、プロバイダは /// 1回だけリフレッシュされます。
/// 一度だけです。
///
/// [invalidateSelf]を呼び出すと、プロバイダは即座に破棄されます。
void invalidateSelf()
  • プロバイダーの状態を無効にし、リフレッシュさせる。
  • リフレッシュは即時ではなく、次の読み取りまたは次のフレームまで遅延される。
  • [invalidateSelf]を複数回呼び出すと、プロバイダは /// 1回だけリフレッシュされます。
  • [invalidateSelf]を呼び出すと、プロバイダは即座に破棄されます。

だそうです。プロバイダーの状態を無効にし、リフレッシュさせるのか....

futureっていうのは内部実装をみると、riverpodのコードみたい。

/// この[AsyncNotifier]に関連付けられたプロバイダの[Ref]。
  保護された
  Ref<AsyncValue<State>> get ref;

  /// {@template riverpod.async_notifier.future} /// [Future]を取得します。
  /// でない最初の[状態]値で解決する[Future]を取得します。
  /// [AsyncLoading]ではありません。
  ///
  /// この未来は必ずしも[AsyncNotifier.build]の完了を待つとは限りません。
  /// [AsyncNotifier.build]が完了する前に[state]が変更された場合、[future]はその新しい[state]で解決されます。
  /// その新しい[state]の値で解決されます。
  
  /// state] がエラー状態の場合、[future] は失敗します。その場合
  /// その場合、エラーは[AsyncValue.error]と同じスタックトレースになります。
  /// この場合、エラーは[AsyncValue.error]とそのスタックトレースと同じになります。
  可視化テスト
  保護された
  Future<State>を取得する future {.
    _element.flush()return _element.futureNotifier.value;
  }

このコードは、非同期操作の状態を管理するためのAsyncNotifierクラスの一部です。

Ref<AsyncValue<State>> get ref;は、このAsyncNotifierに関連付けられたプロバイダからRefを取得するためのゲッターです。

Future<State> get future;は、非同期操作の結果を表すFutureを取得するためのゲッターです。このFutureは、最初の非ローディング状態の値で解決します。AsyncNotifier.buildが完了する前に状態が変更された場合、その新しい状態の値で解決します。状態がエラーの場合、Futureは失敗し、エラーはAsyncValue.errorとそのスタックトレースと同じになります。

futureとは非同期操作の結果を表すFutureを取得するためのゲッターでした。

完成品のソースコード

Discussion