【Flutter】Riverpodの注意点と改善ポイント：初心者から中級者へのステップアップガイド

## はじめに

Riverpodは非常に便利な状態管理パッケージですが、適切に使用しないと思わぬエラーやパフォーマンス低下を招いてしまうことがあります。
この記事では、Riverpodを使用する際に注意すべきポイントについて、最近学んだ知識を元に詳しく解説していきます。

今回はRiverpodをbuild_runnerで自動生成する前提ですのでその点ご了承ください。

https://pub.dev/packages/riverpod

https://riverpod.dev/ja/docs/introduction/getting_started

### 記事の対象者

- Riverpodの基本的な使い方を理解している方
- Riverpodの利用における注意点を知りたい方

### 記事を執筆時点での筆者の環境

```
[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.90.0)
```

### サンプルプロジェクト

今回は、以前の記事でも取り扱っていたプロジェクトを使用して説明していきます。
このプロジェクトは、各種の値を保存・表示する単純なアプリです。
設定する値は以下の通りです。

- bool値の設定
- intの設定
- Stringの設定
- CustomSetting型の設定（JSONに変換してStringで保存）

![【Flutter】riverpodを使ったDIとレイヤードアーキテクチャ、特にデータ層について（仮）.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/7d7b8121-d8c8-2d2e-e689-da66f09d84b2.gif)

#### ソースコード

https://github.com/HaruhikoMotokawa/shared_preference_sample

#### 以前書いた記事

https://qiita.com/Harx02663971/items/e04b059f5148c34f7a60

https://qiita.com/Harx02663971/items/1e9d8efc68c88385ac7a

## 1. `watch`するのはできるだけ小さな`Widget`単位にする

状態を監視して、状態によって`Widget`を出し分けたり表示を変えたりすることがあります。
しかし、それを監視する場所を考えないと思いもよらぬパフォーマンス低下につながります。
題目にある通り、プロバイダーを`watch`するのはできるだけ画面などの全体で行うのは避けましょう。
何故なら全ての状態を全体で`watch`した場合、同じく全体の`builed`が走ってしまうからです。
以下で実験してみましょう。

### 1-1. 単体で状態を監視している場合

`MyHomePage`は画面全体の`Widget`です。
そこで各設定を表示する`InfoListTile`を`Consumer`Widgetでラップしています。

`Consumer`でラップしている理由は以下の例で言うと`iconSettingProvider`をこの`Consumer`でラップしているWidgetだけで監視したいからです。

その他の`Consumer`でラップしている場所ではそれぞれ`backgroundColorNumber`、`titleText`、`customSetting`を監視しています。

```lib/presentations/my_home_page/my_home_page.dart

/// ホーム画面
class MyHomePage extends HookConsumerWidget {
  /// ホーム画面のコンストラクタ
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {

  // 省略

   logger.d('画面全体のビルドです');
   return Scaffold(
      body: SafeArea(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [

      // 省略
      
              Flexible(
                child: Consumer(
                  builder: (context, ref, child) {
                    final iconSetting = ref.watch(iconSettingProvider);
                    return InfoListTile(
                      value: iconSetting.valueOrNull,
                      type: TileType.iconSetting,
                    );
                  },
                ),
              ),
              Flexible(
                child: Consumer(
                  builder: (context, ref, child) {
                    final backgroundColorNumber =
                        ref.watch(backgroundColorNumberProvider);
                    return InfoListTile(
                      value: backgroundColorNumber.valueOrNull,
                      type: TileType.backgroundColorNumber,
                    );
                  },
                ),
              ),
              Flexible(
                child: Consumer(
                  builder: (context, ref, child) {
                    final titleText = ref.watch(titleTextProvider);
                    return InfoListTile(
                      value: titleText.valueOrNull,
                      type: TileType.titleText,
                    );
                  },
                ),
              ),
              Flexible(
                child: Consumer(
                  builder: (context, ref, child) {
                    final customSetting = ref.watch(customSettingProvider);
                    return InfoListTile(
                      value: customSetting.valueOrNull,
                      type: TileType.customSetting,
                    );
                  },
                ),
              ),
              
    //省略
    
```
#### `builed`の挙動を確認してみる

ここでアイコン設定を変更した場合の`builed`の挙動を見てみます。
`InfoListTile`の`builed`には`logger`を出すようにしています。

```lib/presentations/shared/info_list_tile.dart
class InfoListTile extends StatelessWidget {

  // 省略

  /// Tileのタイプ
  final TileType type;
  @override
  Widget build(BuildContext context) {
    logger.d('${type.title}のタイルをビルド');

    // 省略
    
```
#### アプリを立ち上げた最初のログ

最初に画面全体の`builed`が走ります。

次にそれぞれの`InfoListTile`が`builed`されています。

最後に各プロバイダの初期値が流れたことによって再度`builed`が走ります。
今回watchしているプロバイダーはStream型です。
Stream型は最初に監視が始まると初期値が流れる仕組みになっています。
よって今回は`InfoListTile`が`watch`している値が全てStream型のため全てが再ビルドされています。

![スクリーンショット 2024-06-08 14.59.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/05eeb241-a1e5-dc3c-d1e1-3b77cc6f6fae.png)

#### アイコンの設定を変えてみる

一旦ログを削除した状態で、アイコン設定を変えてみます。

![アイコン設定変更GIF.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/91a6ff58-c15f-e51e-5780-b3cb460f5ea9.gif)

するとアイコン設定を監視している`InfoListTile`だけが`builed`されました。

![スクリーンショット 2024-06-08 15.21.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/ad17d6f7-4b62-7aa9-b2fa-45f0e5fae995.png)

### 1-2. 画面全体で監視してみる

次に画面全体で監視した場合です。
`isWatchProviderFromPage`というフラグを`true`に変更すると、
画面全体で`final iconSetting = ref.watch(iconSettingProvider);`を実行し、
その内容を表示する`testIconSettingWidget`を表示するようにしています。

```lib/presentations/my_home_page/my_home_page.dart

class MyHomePage extends HookConsumerWidget {
  /// ホーム画面のコンストラクタ
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    /// 画面全体で値をwatchするテストを行うかどうかのフラグ
    final isWatchProviderFromPage = useState(false);

    /// テスト用のwidget
    Widget? testIconSettingWidget;

    // もしisWatchProviderFromPageがtrueの場合は画面全体でiconSettingProviderをwatchし、
    // watchした値を反映するwidgetをtestIconSettingWidgetに設定する
    if (isWatchProviderFromPage.value) {
      final iconSetting = ref.watch(iconSettingProvider);
      testIconSettingWidget = Column(
        children: [
          iconSetting.when(
            data: (data) {
              logger.d('画面でwatchした場合のビルドです');
              return ListTile(
                title: Text('画面でwatchした値の${TileType.iconSetting.title}'),
                trailing: switch (data) {
                  true => const Icon(Icons.power),
                  false => const Icon(Icons.power_off),
                  null => const Text('値がnullです')
                },
              );
            },
            error: (error, stack) {
              logger.e(
                'エラー',
                error: error,
                stackTrace: stack,
              );
              return const Text('エラーです');
            },
            loading: () => const CircularProgressIndicator(),
          ),
          const Divider(),
        ],
      );
    }

    logger.d('画面全体のビルドです');
    return Scaffold(
      body: SafeArea(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // isWatchProviderFromPageがtrueだったら生成する
              if (isWatchProviderFromPage.value &&
                  testIconSettingWidget != null)
                testIconSettingWidget,

    // 省略

              ElevatedButton(
                style: ButtonStyle(
                  backgroundColor: WidgetStateProperty.all(
                    isWatchProviderFromPage.value ? Colors.yellow : Colors.grey,
                  ),
                ),
                onPressed: () {
                  final currentIsWatch = isWatchProviderFromPage.value;
                  final newValue = !currentIsWatch;
                  isWatchProviderFromPage.value = newValue;
                },
                child: Text(
                  '画面全体でのWatchを'
                  '${isWatchProviderFromPage.value ? '停止' : '開始'}',
                ),
              ),


```

#### 画面全体での監視して値を変更する

一番下のボタンをタップして画面全体での`watch`を有効にし、アイコン設定を変更してみます。

![画面全体でwatch.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/16f1e84f-20e1-b86e-5dd1-63fd773929b7.gif)


ログを見てみると、明らかに違いますね。
画面全体でプロバイダーを`watch`すると値の変更があるたびに画面全体が再`builed`されてしまいます。
画面全体の`builed`がされてしまうので、アイコン設定の`InfoListTile`だけではなく
その配下の背景色、タイトル、JSONなどの値を監視している`InfoListTile`まで再度`builed`されてしまっている事がわかります。

![スクリーンショット 2024-06-08 16.12.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/1c6fedc8-cbae-a99c-3b18-93a780ce583c.png)

### 1-3. 結論

`watch`する場合はその値の変更に影響がある`Widget`内で行うようにしよう！

※ 画面全体に再度`builed`を実行させる必要があるばあいはもちろん画面全体で`watch`する必要があります。

## 2. `AsyncValue`は`when`と`valueOrNull`を使い分けよう

### 2-1. `AsyncValue`とは？

今回扱っているプロバイダーは`AsyncValue`で定義しています。
`AsyncValue`は簡単に説明すると、Future型またはStream型で定義されたプロバイダーです。

```lib/data/repositories/key_value_repository/provider.dart

/// アイコン設定の値を提供するStreamを生成
/// `IconSettingRef`を通じてリポジトリにアクセスし、現在のアイコン設定を取得し、
/// その後、アイコン設定が変更されるたびに新しい値を提供する
@riverpod
Stream<bool?> iconSetting(IconSettingRef ref) async* {
  // キー値リポジトリのプロバイダーからリポジトリオブジェクトを取得
  final repository = ref.read(keyValueRepositoryProvider);

  // 最初のアイコン設定の値を取得し、yieldを使用してStreamに出力
  yield await repository.getIconSetting();

  // リポジトリの値変更通知を購読し、アイコン設定キーの変更のみにフィルターをかける
  await for (final _ in repository.onValueChange
      .where((key) => key == KeyValueRepository.iconSettingKey)) {
    // アイコン設定キーが変更されたとき、新しいアイコン設定の値を取得し、yieldでStreamに出力
    yield await repository.getIconSetting();
  }
}

```

`AsyncValue`についてこちらの記事で丁寧に解説されています。

https://zenn.dev/tsuruo/articles/52f62fc78df6d5#whendata
### 2-2. `when`メソッドでWidgetを出し分ける

`AsyncValue`を使ってWidgetを表示しようとすると一般的にはwhenで実装することになると思います。
`AsyncValue`は非同期で値を受け取るので、その場合のハンドリングが行いやすいです。

- `data` : 値の取得に成功した場合
- `error` : 値の取得に失敗した場合
- `loading` : 値を取得している最中の場合

```lib/presentations/my_home_page/my_home_page.dart

          final iconSetting = ref.watch(iconSettingProvider);

          iconSetting.when(
            data: (data) {
              logger.d('画面でwatchした場合のビルドです');
              return ListTile(
                title: Text('画面でwatchした値の${TileType.iconSetting.title}'),
                trailing: switch (data) {
                  true => const Icon(Icons.power),
                  false => const Icon(Icons.power_off),
                  null => const Text('値がnullです')
                },
              );
            },
            error: (error, stack) {
              logger.e(
                'エラー',
                error: error,
                stackTrace: stack,
              );
              return const Text('エラーです');
            },
            loading: () => const CircularProgressIndicator(),
          ),

```

### 2-3. `valueOrNull`で実装する

`when`は確かに出し分けるのに大変わかりやすいのですが、コード量が増えます。
さらにいうと今回の例の状態はネットワーク通信をしているわけでもなく、画像処理などの時間のかかる処理をしているわけではありません。
値を取得する上でエラーになる可能性も少ないです。
なのでloadingやerrorを省きたいです。

そこで登場するのが`valueOrNull`です。
`valueOrNull`は`AsyncValue`から直接値を参照するメソッドです。
似たようなもので`value`がありますが、これは失敗した場合`exception`が`throw`されるため、
基本的には`valueOrNull`を利用するのがベターでしょう。

```lib/presentations/my_home_page/my_home_page.dart

              Flexible(
                child: Consumer(
                  builder: (context, ref, child) {
                    final iconSetting = ref.watch(iconSettingProvider);
                    return InfoListTile(
                      value: iconSetting.valueOrNull,
                      type: TileType.iconSetting,
                    );
                  },
                ),
              ),

```

ここでは`iconSetting.valueOrNull`だけを渡していますが、注意しければいけないのはnull許容だという事です。
今回でいうと`InfoListTile`の方でnullだった場合はハンドリングしているのでそのまま渡しています。
そうではない場合、デフォルト値を渡しておくのが安全です。

```dart

 value: iconSetting.valueOrNull ?? false,
 
```

### 2-4. 総括

対象の値がどんな処理によって生成されるのかを見極めて、`when`と`valueOrNull`を使い分けよう

## 3. 値を引数で渡したり、useStateでの管理を検討する

*1. watchするのはできるだけWidget単位にする*　でも触れたように、
状態を取得するのに全てを`watch`するとパフォーマンス低下につながる場合もあります。
その状態を監視ではなくて、その値を引き渡すだけでいい場合もあります。

また、一時的に保持したいだけなら`StatefullWidegt`の`state`か、
flutter_hooksパッケージによる`HookWidget`の`useState`を検討しましょう。

https://pub.dev/packages/flutter_hooks

今回はCustomSettingの編集保存の場合を例に説明します。
また、一時保存には`HookWidget`の`useState`を採用しています。

以下が主な機能です。

1. ホーム画面のボタンを押すとCustomSettingを編集する専用のページに遷移します
2. 編集画面ではすでに保存されているCustomSettingの値を元に表示をします
3. 編集している最中の値は一時的に編集画面で保持します
4. 保存ボタンを押すと変更内容が保存されます

以下のGifではちょっとわかりづらいですが、編集画面で一度保存せずにホーム画面へ戻っています。
その後もう一度編集画面で編集後に保存ボタンを押してホーム画面に戻ると保存内容が表示されています。

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/969c67e6-24a1-8a89-ea80-f4335d2d6762.gif)

### 3-1. 状態を引数で渡す

編集画面に必要なのは現在の設定値です。
今回のCustomSettingの状態は保存されている値です。
保存されていない編集中に状態が変わることはあり得ないので、`watch`する必要がない（してはいけない）です。
よって編集画面である`EditCustomSettingPage`で`customSettingProvider`を`watch`するのではなく遷移前に現時点での値を引数で渡してあげるのが良いと考えます。

```lib/presentations/my_home_page/my_home_page.dart

              ElevatedButton(
                child: const Text('複数の条件を変更'),
                onPressed: () async {
                  // customSettingProviderはAsyncValueなので現時点での値を読み取る場合は
                  // .futureで待つ必要がある
                  final customSetting =
                      await ref.read(customSettingProvider.future);
                  if (context.mounted) {
                    await Navigator.push(
                      context,
                      MaterialPageRoute<EditCustomSettingPage>(
                        builder: (builder) => EditCustomSettingPage(
                          iconSetting: customSetting?.iconSetting,
                          backgroundColorNumber:
                              customSetting?.backgroundColorNumber,
                          titleText: customSetting?.titleText,
                        ),
                      ),
                    );
                  }
                },
              ),

```

### 3-2. 一時的な状態を保持する

最初に述べた、このアプリの機能で次のものがあります。

>編集している最中の値は一時的に編集画面で保持します

つまり一時的に設定内容を保持するのですが、その場合は`HookWidegt`の`useState`が便利です。
`EditCustomSettingPage`のコンストラクタで受け取った各設定値を画面のビルド時に`useEffect`内で`useState`に設定します。

各設定値を表示する場合はそれぞれの`useState`の値を使って表示します。

```lib/presentations/edit_custom_setting_page/edit_custom_setting_page.dart

/// CustomSettingを編集する画面
class EditCustomSettingPage extends HookWidget {
  /// CustomSettingを編集する画面
  const EditCustomSettingPage({
    this.iconSetting,
    this.backgroundColorNumber,
    this.titleText,
    super.key,
  });

  /// アイコン設定
  final bool? iconSetting;

  /// 背景色番号
  final int? backgroundColorNumber;

  /// タイトルテキスト
  final String? titleText;

  @override
  Widget build(BuildContext context) {
    // 一時的に変更内容を保持するためのステート達
    final iconSettingState = useState<bool?>(null);
    final backgroundColorNumberState = useState<int?>(null);
    final titleTextState = useState<String?>(null);

    // 画面が生成された時にそれぞれの設定内容をuseStateに代入
    useEffect(
      () {
        iconSettingState.value = iconSetting;
        backgroundColorNumberState.value = backgroundColorNumber;
        titleTextState.value = titleText;
        return null;
      },
      [],
    );
    return Scaffold(
      appBar: AppBar(
        title: const Text('カスタム設定編集'),
      ),
      body: Center(
        child: Column(
          children: [
            Flexible(
              child: InfoListTile(
                value: iconSettingState.value,
                type: TileType.iconSetting,
              ),
            ),
            Flexible(
              child: InfoListTile(
                value: backgroundColorNumberState.value,
                type: TileType.backgroundColorNumber,
              ),
            ),
            Flexible(
              child: InfoListTile(
                value: titleTextState.value,
                type: TileType.iconSetting,
              ),
            ),
            const Divider(),


```

その後は各ボタンで設定を変更した場合はその値を受け取って`useState`に渡していきます。

```lib/presentations/edit_custom_setting_page/edit_custom_setting_page.dart

            Flexible(
              child: ElevatedButton(
                child: const Text('アイコンの設定を変更'),
                onPressed: () async {
                  final result =
                      await showSelectIconSettingBottomSheet(context);
                  iconSettingState.value = result;
                },
              ),
            ),
            Flexible(
              child: ElevatedButton(
                child: const Text('背景色番号を設定'),
                onPressed: () async {
                  final result = await showSelectColorBottomSheet(context);
                  backgroundColorNumberState.value = result;
                },
              ),
            ),
            Flexible(
              child: ElevatedButton(
                child: const Text('タイトルの文字を設定'),
                onPressed: () async {
                  final result = await showSelectTitleBottomSheet(context);
                  titleTextState.value = result;
                },
              ),
            ),
            const Gap(40),

```
最終的には保存ボタンを押すときに`useState`の値を渡して保存すれば完了です。

```lib/presentations/edit_custom_setting_page/edit_custom_setting_page.dart

            Flexible(
              child: Consumer(
                builder: (context, ref, child) {
                  return SizedBox(
                    width: 300,
                    child: ElevatedButton(
                      child: const Text('保 存'),
                      onPressed: () async {
                        await ref
                            .read(editCustomSettingPageProvider.notifier)
                            .saveCustomSetting(
                              iconSetting: iconSettingState.value,
                              backgroundColorNumber:
                                  backgroundColorNumberState.value,
                              titleText: titleTextState.value,
                            );
                        if (context.mounted) {
                          Navigator.pop(context);
                        }
                      },
                    ),
                  );
                },
              ),
            ),
            

```

```lib/presentations/edit_custom_setting_page/edit_custom_setting_page_view_model.dart

/// EditCustomSettingPageの処理を司るクラス
@riverpod
class EditCustomSettingPage extends _$EditCustomSettingPage {
  @override
  Future<void> build() async {}

  /// カスタム設定を保存する
  Future<void> saveCustomSetting({
    required bool? iconSetting,
    required int? backgroundColorNumber,
    required String? titleText,
  }) async {
    const setting = CustomSetting();
    final updateSetting = setting.copyWith(
      iconSetting: iconSetting,
      backgroundColorNumber: backgroundColorNumber,
      titleText: titleText,
    );
    await ref.read(keyValueRepositoryProvider).setCustomSetting(updateSetting);
  }
}

```

## 終わりに

この記事では、Riverpodを利用する際の注意点について具体例を交えながら解説しました。
特に、状態の監視の範囲を適切に設定すること、AsyncValueの使い分け、一時的な状態の管理方法について詳しく見てきました。これらのポイントを意識することで、アプリケーションのパフォーマンスを向上させ、よりスムーズなユーザー体験を提供することが可能になります。

Riverpodは非常に強力な状態管理パッケージですが、その特性を理解し、正しく利用することで、さらにその効果を最大限に引き出すことができます。
今回の内容が、Riverpodを使った開発の助けとなれば幸いです。

何か質問や追加の情報が必要な場合は、遠慮なくお知らせください。