【Flutter】Notifier で 初期値と初期化処理を分離したい

## StateNotifier が今後非推奨に！？
Remi さんにより、StateNotifier が非推奨になるようなことが示唆されています。

https://twitter.com/remi_rousselet/status/1519985430426537984?s=20


また、公式ドキュメントには　NotifierProvider を使用するように勧められています。
>Prefer using NotifierProvider instead.

https://docs-v2.riverpod.dev/docs/providers/state_notifier_provider

今回は、StateNotifier -> Notifier([riverpod_generator](https://pub.dev/packages/riverpod_generator))への移行で躓いた点を解説していきます


### この記事で解説すること
- StateNotifier -> Notifier への置き換え

### この記事で解説しないこと
- riverpod_generator による Provider 作成手順
- freezed の使い方

### 使用ライブラリ
```pubspec.yaml
dependencies:
  hooks_riverpod: ^2.3.6
  freezed_annotation: ^2.2.0
  riverpod_annotation: ^2.1.1
  flutter_hooks: ^0.18.6

dev_dependencies:
  build_runner: ^2.3.3
  freezed: ^2.3.5
  riverpod_generator: ^2.2.3
```

## 前提
- StateNotifier では freezed の Union Type を使って状態を表現している
- AsyncNotifier ではなく、Notifier に変更する

<details>

<summary>main.dart(メイン画面)</summary>

```dart:main.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:state_notifier_to_generated_notifier_sample/sample_view_model.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: SamplePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(sampleViewModelProvider);
    return Scaffold(
      body: state.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        empty: () => const SizedBox(),
        list: (todos) => ListView.builder(
          itemBuilder: (context, index) => ListTile(
            title: Text(
              todos[index].title,
            ),
          ),
          itemCount: todos.length,
        ),
        error: () => Center(
          child: TextButton(
             child: const Text('Retry'),
             onPressed: () {
               ref.read(sampleViewModelProvider.notifier).fetch();
             },
          ),
        ),
      ),
    );
  }
}
```
</details>

<details>

<summary>sample_state.dart(画面を管理する Sampletate クラスと Todo クラス)</summary>

```dart: sample_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'sample_state.freezed.dart';

@freezed
class SampleState with _$SampleState {
  const factory SampleState.loading() = SampleStateFirstLoading;

  const factory SampleState.empty() = SampleStateEmpty;

  const factory SampleState.list({
    required List<Todo> todos,
  }) = SampleStateList;

  const factory SampleState.error() = SampleStateError;

  const SampleState._();
}

@freezed
class Todo with _$Todo {
  const factory Todo({
    required String id,
    required String title,
    @Default(false) bool completed,
  }) = TodoData;
}

```

</details>

<details>

<summary>sample_view_model.dart(SamplePage に対応する ViewModel)</summary>

```dart: sample_view_model.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:state_notifier_to_generated_notifier_sample/model/sample_data_state.dart';

final sampleViewModelProvider =
    StateNotifierProvider.autoDispose<SampleViewModel, SampleState>(
        (_) => SampleViewModel(const SampleState.empty()));

class SampleViewModel extends StateNotifier<SampleState> {
  SampleViewModel(
    SampleState state,
  ) : super(state) {
    fetch();
  }

  Future<void> fetch() async {
    state = const SampleState.loading();

    try {
      final todos =
          await Future.delayed(const Duration(milliseconds: 1500)).then(
        (value) => [
          const Todo(id: 1, title: 'buy a fish', isCompleted: false),
        ],
      );

      state = SampleState.list(todos: todos);
    } on Exception {
      state = const SampleState.error();
    }
  }
}
```

</details>

### 動作
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2552610/30295116-9788-c1c3-650c-91d28ae70605.gif" width="400">



## riverpod_generator で置き換え(不十分)

```dart
part 'sample_view_model.g.dart';

@riverpod
class SampleViewModel extends _$SampleViewModel {
  @override
  SampleState build({SampleState initState = const SampleState.empty()}) {
    fetch();
    return initState;
  }

  Future<void> fetch() async {
    state = const SampleState.loading();

    try {
      final todos =
          await Future.delayed(const Duration(milliseconds: 1500)).then(
        (value) => [
          const Todo(id: 1, title: 'buy a fish', isCompleted: false),
        ],
      );

      state = SampleState.list(todos: todos);
    } on Exception {
      state = const SampleState.error();
    }
  }
}
```

### 実行してみると…
loading 画面が表示されていません！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2552610/a5e597af-e751-f2d0-97e1-633eb178befa.gif" width="400">



期待：empty　画面 -> loading　画面　-> リスト画面
動作：loading　画面　-> empty　画面 ->  リスト画面

`fetch`メソッドは完了を待機しているわけではないので、`SampleState.loading()`の後に、`initState = const SampleState.empty()`で上書きされてしまうのです。

つまり、 今回のようなケースにおいて単純に実装するだけでは
**Notifier では初期化処理と初期値を分離できない**
ということがわかりました。

## 対応案1: Widget側で初期化

```dart:sample_view_model.dart
@riverpod
class SampleViewModel extends _$SampleViewModel {
  @override
  SampleState build({SampleState initState = const SampleState.empty()}) {
    return initState; // 初期化処理(fetch())を削除
    }
    // 省略
  }
```

```dart:main.dart
class SamplePage extends HookConsumerWidget {  // <-- ①
  const SamplePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ②
    useEffect(
      () {
        WidgetsBinding.instance.addPostFrameCallback((_) {
          ref.read(sampleViewModelProvider().notifier).fetch();
        });

        return null;
      },
      const [], // <-- ③
    );

    final state = ref.watch(sampleViewModelProvider());
    return Scaffold(
      body: state.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        empty: () => const SizedBox(),
        list: (todos) => ListView.builder(
          itemBuilder: (context, index) => ListTile(
            title: Text(
              todos[index].title,
            ),
          ),
          itemCount: todos.length,
        ),
        error: () => Center(
          child: TextButton(child: const Text('Retry'), onPressed: () {
                ref.read(sampleViewModelProvider().notifier).fetch();
            }),
        ),
      ),
    );
  }
}
```
#### ① ConsumerWidget -> HookConsumerWidget
useEffect()を利用できるようにします。

#### ② useEffect
初期化処理を呼び出します。

#### ③ key を const []に指定
const []に指定することで、初回 build 時のみ実行されます。

### WidgetsBinding.instance.addPostFrameCallback とは？
実は、単純に　useEffect　内に初期化処理を実行すると以下のエラーが発生するケースがあります。
useEffectを以下のように変更し build してみると…

```dart
    useEffect(
      () {
        ref.read(sampleViewModelProvider().notifier).fetch();
        return null;
      },
      const [],
    );
```

```
[VERBOSE-2:dart_vm_initializer.cc(41)] Unhandled Exception: Tried to modify a provider while the widget tree was building.
```
**Widgeet ツリーを build している時に、provider が変更されようとしました。**
という例外が発生してしまうのです。

[addPostFrameCallback](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html) は build 完了を保証してくれるため、build 中に provider を変更することが無くなります。


## 対応案2 状態管理を再考する
今回はあえて 独自 State のまま置き換えを行いましたが、AsyncValue　による状態管理に移行し、AsyncNotifier に置き換えることも一つの手段です。

## 最後に
今回は StateNotifier -> Notifier に移行する際に躓いたポイントについて解説しました。
他にも対応策がある場合はぜひ教えて頂きたいです！