🪝

【Flutter】hooks の useState, useEffect の中身をみてみる

2024/12/10に公開

初めに

今回は flutter_hooks に用意されている useStateuseEffect についてまとめて見たいと思います。普段の開発でも useStateuseEffect は多用しているため、今回はその使い方を簡単にまとめ、内部でどのような処理を行なっているのかについて見ていきたいと思います。

記事の対象者

  • Flutter 学習者
  • flutter_hooks の内部について少し知りたい方

目的

今回の目的は flutter_hooksuseStateuseEffect の使い方や内部実装を見ていくことです。どのような仕組みで動いているかを簡単に見てみたいと思います。

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),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

実行結果

https://youtube.com/shorts/n69fTn73L7s

内部実装

ここから少し内部実装を見ていきます。
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();
}

以下は _StateHookcreateState で生成される _StateHookState の実装です。
_stateValueNotifier に初期値を代入した形で遅延初期化され、 addListener_listener を追加しています。

_listenersetState をただ呼び出すだけのメソッドであり、 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!._usehook を入れたものを返しています。

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),
                    ],
                  ),
      ),
    );
  }
}

実行結果

https://youtube.com/shorts/O_y1SpjZR5I

内部実装

ここから少し内部実装を見ていきます。
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() 型の effectList<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 に渡しています。
_EffectHookHook<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;
    }
}

実際に shouldPreserveStateHookElement の中で以下のようにして使われます。
previousHookhook を引数に入れて比較し、もし二つの値が異なる場合は _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 の定義である以下の二点が満たされるようになります。

  1. keys が指定されていない限り、毎回のビルド時に同期的に呼び出される
  2. keys が指定されている場合、keys 内のいずれかの値が変更された場合にのみ再度呼び出される

以上です。

まとめ

最後まで読んでいただいてありがとうございました。
今回は flutter_hooks の useStateuseEffect の内部実装を中心に見てきました。
今まで何気なく使っていて、 useState の具体的な仕組みや useEffect の第二引数の値の変化をどのように感知しているかなど知りませんでしたが、今回調べてみて面白いなと感じました。

flutter_hooks にはこれ以外にも複数の機能が用意されているので、その使い方などもまとめられたらと思います。

誤っている点などあればご指摘いただければ幸いです。

参考

https://pub.dev/packages/flutter_hooks

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useState.html

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html

Discussion