14
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

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

Posted at

StateNotifier が今後非推奨に!?

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

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

Prefer using NotifierProvider instead.

今回は、StateNotifier -> Notifier(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 に変更する
main.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();
             },
          ),
        ),
      ),
    );
  }
}
sample_state.dart(画面を管理する Sampletate クラスと Todo クラス)
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;
}

sample_view_model.dart(SamplePage に対応する ViewModel)
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();
    }
  }
}

動作

riverpod_generator で置き換え(不十分)

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 画面が表示されていません!

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

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

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

対応案1: Widget側で初期化

sample_view_model.dart
@riverpod
class SampleViewModel extends _$SampleViewModel {
  @override
  SampleState build({SampleState initState = const SampleState.empty()}) {
    return initState; // 初期化処理(fetch())を削除
    }
    // 省略
  }
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 してみると…

    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 は build 完了を保証してくれるため、build 中に provider を変更することが無くなります。

対応案2 状態管理を再考する

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

最後に

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

14
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?