StateNotifier が今後非推奨に!?
Remi さんにより、StateNotifier が非推奨になるようなことが示唆されています。
また、公式ドキュメントには NotifierProvider を使用するように勧められています。
Prefer using NotifierProvider instead.
今回は、StateNotifier -> Notifier(riverpod_generator)への移行で躓いた点を解説していきます
この記事で解説すること
- StateNotifier -> Notifier への置き換え
この記事で解説しないこと
- riverpod_generator による Provider 作成手順
- freezed の使い方
使用ライブラリ
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(メイン画面)
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 クラス)
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)
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側で初期化
@riverpod
class SampleViewModel extends _$SampleViewModel {
@override
SampleState build({SampleState initState = const SampleState.empty()}) {
return initState; // 初期化処理(fetch())を削除
}
// 省略
}
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 に移行する際に躓いたポイントについて解説しました。
他にも対応策がある場合はぜひ教えて頂きたいです!