【Flutter】hooks の useState, useEffect の中身をみてみる
初めに
今回は flutter_hooks に用意されている useState
と useEffect
についてまとめて見たいと思います。普段の開発でも useState
と useEffect
は多用しているため、今回はその使い方を簡単にまとめ、内部でどのような処理を行なっているのかについて見ていきたいと思います。
記事の対象者
- Flutter 学習者
- flutter_hooks の内部について少し知りたい方
目的
今回の目的は flutter_hooks の useState
と useEffect
の使い方や内部実装を見ていくことです。どのような仕組みで動いているかを簡単に見てみたいと思います。
useState
まずは useState
について見ていきます。
useState
は flutter_hooks を扱う場合は必ずと言って良いほど使うかなと思います。
概要に関しては公式ドキュメントがそのままわかりやすいので、日本語訳して引用します。
Creates a variable and subscribes to it.
Whenever ValueNotifier.value updates, it will mark the caller HookWidget as needing a build. On the first call, it initializes ValueNotifier to initialData. initialData is ignored on subsequent calls.(日本語訳)
変数を作成し、それに購読します。
ValueNotifier.value が更新されるたびに、呼び出し元の HookWidget が再ビルドを必要としているとマークされます。初回の呼び出し時には、ValueNotifier が initialData で初期化されます。以降の呼び出しでは、initialData は無視されます。
実装例
useState
を使えば、以下のようなカウントアプリが作成できます。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class UseStateSample extends HookWidget {
const UseStateSample({super.key});
Widget build(BuildContext context) {
final count = useState(0);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${count.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
color: Colors.red,
onPressed: () => count.value++,
icon: const Icon(Icons.add),
),
IconButton(
color: Colors.blue,
onPressed: () => count.value--,
icon: const Icon(Icons.remove),
),
],
),
],
),
),
);
}
}
実行結果
内部実装
ここから少し内部実装を見ていきます。
useState
を使用している部分に飛ぶと以下のようなコードが出てきます。
行数自体はそこまで多くないかなと思います。
ValueNotifier<T> useState<T>(T initialData) {
return use(_StateHook(initialData: initialData));
}
class _StateHook<T> extends Hook<ValueNotifier<T>> {
const _StateHook({required this.initialData});
final T initialData;
_StateHookState<T> createState() => _StateHookState();
}
class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
late final _state = ValueNotifier<T>(hook.initialData)
..addListener(_listener);
void dispose() {
_state.dispose();
}
ValueNotifier<T> build(BuildContext context) => _state;
void _listener() {
setState(() {});
}
Object? get debugValue => _state.value;
String get debugLabel => 'useState<$T>';
}
それぞれ流れを追っていきます。
以下は useState
の実装で、 use
メソッドに _StateHook
を入れてその返り値を返しています。
返り値は ValueNotifier
であり initialData
の型に合わせた ValueNotifier
が返却されていることがわかります。
ValueNotifier<T> useState<T>(T initialData) {
return use(_StateHook(initialData: initialData));
}
以下は use
メソッドの引数に入れていた _StateHook
です。
createState
で _StateHookState
を生成しています。
class _StateHook<T> extends Hook<ValueNotifier<T>> {
const _StateHook({required this.initialData});
final T initialData;
_StateHookState<T> createState() => _StateHookState();
}
以下は _StateHook
の createState
で生成される _StateHookState
の実装です。
_state
は ValueNotifier
に初期値を代入した形で遅延初期化され、 addListener
で _listener
を追加しています。
_listener
は setState
をただ呼び出すだけのメソッドであり、 ValueNotifier
の値が変化するたびに呼び出されてリビルドが走るようになっています。
class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
late final _state = ValueNotifier<T>(hook.initialData)
..addListener(_listener);
void dispose() {
_state.dispose();
}
ValueNotifier<T> build(BuildContext context) => _state;
void _listener() {
setState(() {});
}
Object? get debugValue => _state.value;
String get debugLabel => 'useState<$T>';
}
use メソッドの中身
use
メソッドは以下のようになっています。
Hook.use
に受け取った hooks
を渡しています。
R use<R>(Hook<R> hook) => Hook.use(hook);
Hook.use
は以下のようになっています。
_currentHookElement
がない場合はビルドメソッド内で use が呼び出されていないことを示すアサーションを出しています。
返り値には HookElement._currentHookElement!._use
で hook
を入れたものを返しています。
static R use<R>(Hook<R> hook) {
assert(HookElement._currentHookElement != null, '''
Hooks can only be called from the build method of a widget that mix-in `Hooks`.
Hooks should only be called within the build method of a widget.
Calling them outside of build method leads to an unstable state and is therefore prohibited.
''');
return HookElement._currentHookElement!._use(hook);
}
以下は HookElement._currentHookElement!._use
の _use
メソッドです。
R _use<R>(Hook<R> hook) {
if (_currentHookState == null) {
// 現在処理中のフック状態 _currentHookState が null である場合
// 新しい hook をリストに追加するために _appendHook メソッドを呼び出し
_appendHook(hook);
} else if (hook.runtimeType != _currentHookState!.value.hook.runtimeType) {
// 新しい hook のランタイムタイプが現在の hook のランタイムタイプと異なる場合
final previousHookType = _currentHookState!.value.hook.runtimeType;
_unmountAllRemainingHooks();
if (kDebugMode && _debugDidReassemble) {
_appendHook(hook);
} else {
throw StateError('''
Type mismatch between hooks:
- previous hook: $previousHookType
- new hook: ${hook.runtimeType}
''');
}
} else if (hook != _currentHookState!.value.hook) {
// 新しい hook が現在の hook と異なる場合。
final previousHook = _currentHookState!.value.hook;
if (Hook.shouldPreserveState(previousHook, hook)) {
_currentHookState!.value
.._hook = hook
..didUpdateHook(previousHook);
} else {
_needDispose ??= LinkedList();
_needDispose!.add(_Entry(_currentHookState!.value));
_currentHookState!.value = _createHookState<R>(hook);
}
}
// _currentHookState の build メソッドを呼び出し、結果を取得
// この時点でリビルドが発生
final result = _currentHookState!.value.build(this) as R;
assert(() {
_currentHookState!.value._debugLastBuiltValue = result;
return true;
}(), '');
_currentHookState = _currentHookState!.next;
return result;
}
基本的に flutter_hooks の use〇〇 はこの use
メソッドを使用して実装されています。
内部的には ValueNotifier
を使用しているため、以下のようなコードで useState
のある程度挙動の再現はできるかと思います。
class ValueNotifierSample extends StatefulWidget {
const ValueNotifierSample({super.key});
ValueNotifierSampleState createState() => ValueNotifierSampleState();
}
class ValueNotifierSampleState extends State<ValueNotifierSample> {
// ValueNotifier を使用して状態を管理
final ValueNotifier<int> _counter = ValueNotifier<int>(0);
void initState() {
super.initState();
// ValueNotifier のリスナーを追加
_counter.addListener(_onCounterChanged);
}
void _onCounterChanged() {
// ValueNotifier の値が変更されたらウィジェットを再ビルド
setState(() {});
}
void dispose() {
// リスナーを削除し、ValueNotifier を破棄
_counter.removeListener(_onCounterChanged);
_counter.dispose();
super.dispose();
}
void _increment() {
_counter.value += 1;
}
void _decrement() {
_counter.value -= 1;
}
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${_counter.value}',
style: Theme.of(context).textTheme.headlineMedium,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
color: Colors.red,
onPressed: _increment,
icon: const Icon(Icons.add),
),
IconButton(
color: Colors.blue,
onPressed: _decrement,
icon: const Icon(Icons.remove),
),
],
),
],
),
),
);
}
}
useEffect
次に useEffect について見ていきます。
こちらに関しても公式ドキュメント を引用します。
Useful for side-effects and optionally canceling them.
useEffect is called synchronously on every build, unless keys is specified. In which case useEffect is called again only if any value inside keys has changed.
It takes an effect callback and calls it synchronously. That effect may optionally return a function, which will be called when the effect is called again or if the widget is disposed.
By default effect is called on every build call, unless keys is specified. In which case, effect is called once on the first useEffect call and whenever something within keys change(日本語訳)
副作用を実行したり、必要に応じてキャンセルするのに便利です。
useEffectは、keysが指定されていない限り、毎回のビルド時に同期的に呼び出されます。keysが指定されている場合、keys内のいずれかの値が変更された場合にのみ再度呼び出されます。
useEffectはエフェクトコールバックを受け取り、それを同期的に呼び出します。そのエフェクトはオプションで関数を返すことができます。この関数は、エフェクトが再度呼び出されるとき、またはウィジェットが破棄されるときに呼び出されます。
デフォルトでは、effectはすべてのビルド呼び出し時に実行されます。ただし、keysが指定されている場合、useEffectの初回呼び出し時と、keys内の何かが変更されたときにのみ実行されます。
まとめると以下のようなことが言えるかと思います。
- 副作用の実行、キャンセルに使用できる
- keys が指定されない限りビルドごとに実行される
実装例
useEffect
を利用すると、以下のようにデータを初めに取得して、必要に応じて再取得するような実装ができます。通常であればデータの再取得は Provider の invalidate
などを使用するかと思いますが、今回は useEffect
の例として、第二引数に値を入れて再取得する形にしています。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class UseEffectSample extends HookWidget {
const UseEffectSample({super.key});
Widget build(BuildContext context) {
final data = useState<List<dynamic>>([]);
final isLoading = useState<bool>(false);
final error = useState<String?>(null);
// useState を使用してフェッチのトリガーとなるカウントを管理
final fetchTrigger = useState<int>(0);
// useEffect を使用してデータを取得する副作用を設定
useEffect(() {
Future<void> fetchData() async {
isLoading.value = true;
error.value = null;
try {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
);
if (response.statusCode == 200) {
data.value = json.decode(response.body);
} else {
error.value = 'データの取得に失敗しました: ${response.statusCode}';
}
} catch (e) {
error.value = 'エラーが発生しました: $e';
} finally {
isLoading.value = false;
}
}
fetchData();
return null;
}, [fetchTrigger.value]);
return Scaffold(
appBar: AppBar(
title: const Text('useEffect Sample'),
),
body: Center(
child: isLoading.value
? const CircularProgressIndicator()
: error.value != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
error.value!,
style: const TextStyle(color: Colors.red, fontSize: 18),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// 再取得のトリガー
fetchTrigger.value++;
},
child: const Text('再取得'),
),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: ListView.builder(
itemCount: data.value.length,
itemBuilder: (context, index) {
final item = data.value[index];
return ListTile(
title: Text(item['title']),
subtitle: Text(item['body']),
);
},
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// 再取得のトリガー
fetchTrigger.value++;
},
child: const Text('再取得'),
),
const SizedBox(height: 30),
],
),
),
);
}
}
実行結果
内部実装
ここから少し内部実装を見ていきます。
useEffect
を使用している部分に飛ぶと以下のようなコードが出てきます。
void useEffect(Dispose? Function() effect, [List<Object?>? keys]) {
use(_EffectHook(effect, keys));
}
class _EffectHook extends Hook<void> {
const _EffectHook(this.effect, [List<Object?>? keys]) : super(keys: keys);
final Dispose? Function() effect;
_EffectHookState createState() => _EffectHookState();
}
class _EffectHookState extends HookState<void, _EffectHook> {
Dispose? disposer;
void initHook() {
super.initHook();
scheduleEffect();
}
void didUpdateHook(_EffectHook oldHook) {
super.didUpdateHook(oldHook);
if (hook.keys == null) {
disposer?.call();
scheduleEffect();
}
}
void build(BuildContext context) {}
void dispose() => disposer?.call();
void scheduleEffect() {
disposer = hook.effect();
}
String get debugLabel => 'useEffect';
bool get debugSkipValue => true;
}
useState と似ている部分もありますが、処理の流れを追っていきます。
以下では、Dispose? Function()
型の effect
と List<Object?>?
型の keys
を受け取り、useState と同じ use
メソッドに渡しています。
ドキュメントで以下のような記述があった通り、この第二引数の keys
が変更されるたびに第一引数に入っているメソッドが呼び出されるようになっています。
keys内のいずれかの値が変更された場合にのみ再度呼び出されます
void useEffect(Dispose? Function() effect, [List<Object?>? keys]) {
use(_EffectHook(effect, keys));
}
以下では use
メソッドに渡している _EffectHook
を定義しています。
effect
を受け取っているため、createState
メソッドで生成される _EffectHookState
でも effect
を使用することができます。
keys
も同様に受け取っていますが、これは super
に渡しています。
_EffectHook
は Hook<void>
を継承しているため、 keys
に関する処理を Hook<void>
の方に委譲していることがわかります。
class _EffectHook extends Hook<void> {
const _EffectHook(this.effect, [List<Object?>? keys]) : super(keys: keys);
final Dispose? Function() effect;
_EffectHookState createState() => _EffectHookState();
}
少し Hook
の方の実装を見て、 keys
を受け取ってどのように effect
を実行するかどうかを判断しているかを見てみます。
Hook
の実装に関して、関係のある部分のみを抜粋すると以下のようになっています。
Hook
では List<Object?>?
型の keys
を受け取り、 shouldPreserveState
で使用しています。
shouldPreserveState
ではコードのコメントにある通り、現在の State を保持すべきか破棄すべきかを判断しています。前後の Hook を受け取り、それを比較して同一であるかどうかによって bool を返しています。
abstract class Hook<R> with Diagnosticable {
const Hook({this.keys});
final List<Object?>? keys;
/// The algorithm to determine if a [HookState] should be reused or disposed.
/// This compares [Hook.keys] to see if they contains any difference.
static bool shouldPreserveState(Hook<Object?> hook1, Hook<Object?> hook2) {
final p1 = hook1.keys;
final p2 = hook2.keys;
if (p1 == p2) {
return true;
}
// if one list is null and the other one isn't, or if they have different sizes
if (p1 == null || p2 == null || p1.length != p2.length) {
return false;
}
}
実際に shouldPreserveState
は HookElement
の中で以下のようにして使われます。
previousHook
と hook
を引数に入れて比較し、もし二つの値が異なる場合は _currentHookState
を _needDispose
に入れています。
これによって Hook が破棄されるようになっています。
ここから、「keysの値が変化したら effect の処理が再度走る」ということがわかります。
} else if (hook != _currentHookState!.value.hook) {
final previousHook = _currentHookState!.value.hook;
if (Hook.shouldPreserveState(previousHook, hook)) {
_currentHookState!.value
.._hook = hook
..didUpdateHook(previousHook);
} else {
_needDispose ??= LinkedList();
_needDispose!.add(_Entry(_currentHookState!.value));
_currentHookState!.value = _createHookState<R>(hook);
}
}
_EffectHookState
のコードの方に戻ります。
一部を省略していますが、以下のようになっています。
initHook
では scheduleEffect
を実行しています。この scheduleEffect
の中には disposer = hook.effect()
が含まれており、ここで useEffect
の第一引数の effect
が実行されるようになっています。
また、 didUpdateHook
は Hook に変化があった場合、つまり keys
が変更された段階で呼び出され、そこでも scheduleEffect
が呼ばれ、 effect
が実行されるようになっています。
class _EffectHookState extends HookState<void, _EffectHook> {
Dispose? disposer;
void initHook() {
super.initHook();
scheduleEffect();
}
void didUpdateHook(_EffectHook oldHook) {
super.didUpdateHook(oldHook);
if (hook.keys == null) {
disposer?.call();
scheduleEffect();
}
}
void scheduleEffect() {
disposer = hook.effect();
}
}
これで、 useEffect
の定義である以下の二点が満たされるようになります。
-
keys
が指定されていない限り、毎回のビルド時に同期的に呼び出される -
keys
が指定されている場合、keys
内のいずれかの値が変更された場合にのみ再度呼び出される
以上です。
まとめ
最後まで読んでいただいてありがとうございました。
今回は flutter_hooks の useState
と useEffect
の内部実装を中心に見てきました。
今まで何気なく使っていて、 useState
の具体的な仕組みや useEffect
の第二引数の値の変化をどのように感知しているかなど知りませんでしたが、今回調べてみて面白いなと感じました。
flutter_hooks にはこれ以外にも複数の機能が用意されているので、その使い方などもまとめられたらと思います。
誤っている点などあればご指摘いただければ幸いです。
参考
Discussion