🪝

flutter_hooksを読む

2024/09/25に公開

https://pub.dev/packages/flutter_hooks

https://riverpod.dev/docs/concepts/about_hooks

flutter_hooksは、Flutterの世界にHooksを持ち込むことを目的としたライブラリです。ProviderRiverpodの作者であるRemi Rousselet氏によって開発されています。

A Flutter implementation of React hooks: https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889

Hooks are a new kind of object that manage the life-cycle of a Widget. They exist for one reason: increase the code-sharing between widgets by removing duplicates.

flutter_hooksは、利用者側からすると「useを使っていい感じにhookを呼び出す」ものです。
この仕組みは、「HookElementによるStatelessElement/StatefulElementの拡張」により実現されます。HookElementを理解できれば、flutter_hooksを理解できたといっても過言ではないでしょう。

この記事は、そんなノリでコードを読むものです。ライブラリのバージョンは、執筆時点の最新バージョンである0.21.0を使用します。

HookElement

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/framework.dart#L371-L577

可読性のためにコードやコメントを省略するため、一通り把握してから大元のコードに戻って読むことをおすすめします。

buildメソッド関連

HookElementは、ComponentElementを対象としたmixinです。

https://api.flutter.dev/flutter/widgets/ComponentElement-class.html

次の4メソッドをbuildメソッドに関連するものとして、抜き出して確認しましょう。

ドキュメントを確認する限り、updatereassembleはHot Reload時に呼ばれるメソッドです。実装したPRを見てみると、色々と動作を試しながら実装している様子が見て取れます。[1]

以上の文脈を踏まえ、HookElementdidChangeDependenciesbuildメソッドを整理したものが、次のコードです。[2]

mixin HookElement on ComponentElement {
  static HookElement? _currentHookElement;

  _Entry<HookState<Object?, Hook<Object?>>>? _currentHookState;
  final _hooks = LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>();
  final _shouldRebuildQueue = LinkedList<_Entry<bool Function()>>();
  LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>? _needDispose;
  bool? _isOptionalRebuild = false;
  Widget? _buildCache;

  
  void didChangeDependencies() {
    _isOptionalRebuild = false;
    super.didChangeDependencies();
  }

  
  Widget build() {
    // Check whether we can cancel the rebuild (caused by HookState.mayNeedRebuild).
    final mustRebuild = _isOptionalRebuild != true ||
        _shouldRebuildQueue.any((cb) => cb.value());

    _isOptionalRebuild = null;
    _shouldRebuildQueue.clear();

    if (!mustRebuild) {
      return _buildCache!;
    }

    _currentHookState = _hooks.isEmpty ? null : _hooks.first;
    HookElement._currentHookElement = this;
    try {
      _buildCache = super.build();
    } finally {
      _isOptionalRebuild = null;
      _unmountAllRemainingHooks();
      HookElement._currentHookElement = null;
      if (_needDispose != null && _needDispose!.isNotEmpty) {
        for (_Entry<HookState<dynamic, Hook<dynamic>>>? toDispose =
                _needDispose!.last;
            toDispose != null;
            toDispose = toDispose.previous) {
          toDispose.value.dispose();
        }
        _needDispose = null;
      }
    }

    return _buildCache!;
  }

  void _unmountAllRemainingHooks() {
    if (_currentHookState != null) {
      _needDispose ??= LinkedList();
      // Mark all hooks >= this one as needing dispose
      while (_currentHookState != null) {
        final previousHookState = _currentHookState!;
        _currentHookState = _currentHookState!.next;
        previousHookState.unlink();
        _needDispose!.add(previousHookState);
      }
    }
  }
}

mustRebuildtrueの場合には、_buildCacheを再構築し返却します。mustRebuildfalseの場合には、前回の_buildCacheを返却します。
このmustRebuildは、つぎの2つの条件でtrueになります。

  1. _isOptionalRebuildnullもしくはfalseである
  2. _shouldRebuildQueueの中にtrueを返す関数がある

この条件はHookStatemarkMayNeedRebuildを呼び出したとき、変化します。markMayNeedRebuildは独自のhookを実装する際、利用できるメソッドの1つです。実装的にはHookStateshouldRebuildtrueにすることで、この条件を満たすことができます。

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/framework.dart#L292-L313

ということで、大半のhookはif(!mustRebuild)の分岐に入りません。キャッシュを利用せず、新たにbuildを実行することになります。

buildの処理を読むと「super.build時にHookElement._currentHookElement経由でHookElementにアクセスできる」状態になります。super.buildComponentElement.buildを指しますが、最終的にStatelessWidgetStatebuildメソッドの呼び出しにつながります。こちらは説明不要かなと。

HookElement._currentHookElementは、全面的にhookを導入した場合に利用されます。useContext経由でアクセスするBuildContextが、実はHookElement._currentHookElementです。

BuildContext useContext() {
  assert(
    HookElement._currentHookElement != null,
    '`useContext` can only be called from the build method of HookWidget',
  );
  return HookElement._currentHookElement!;
}

use関連

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

flutter_hooksにおけるuseメソッドは下記のような実装となっており、最終的にHookElement_useメソッドに辿り着きます。

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/framework.dart#L11-L18

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/framework.dart#L125-L140

useメソッドの説明には、次の2文が記載されています。

use must be called within the build method of either HookWidget or StatefulHookWidget. All calls of use must be made outside of conditional checks and always in the same order.

前半部に対応するのが、HookElement._currentHookElementです。

HookElement._currentHookElementには、先ほどのbuildメソッドにてthisが代入されます。この代入はsuper.build()の呼び出し前に行われ、nullで初期化されます。この処理は同期処理です。

後半部に対応するのが、_hooks_currentHookStateです。

_hooksLinkedListで実装されており、_useメソッドの呼び出し時に要素の追加が行われます。ここで重要なのは、LinkedListで実装されているため、追加済みの要素は自身の次の要素を知っています。このため、複数回_useが呼び出された際に、保存済みのlistを辿りながら処理を行うことができます。
この前提を踏まえると、_currentHookStateは、_hooksの中で現在の位置を示すプロパティです。あるHookElementに対して、初めてhookが追加される際には、_currentHookStatenullとなります。その後は呼び出されたhookが_currentHookStateとなり、hookの処理を終えたタイミングで_currentHookState!.next;により次のhookを取得します。なお、リストの最後に辿り着いた場合には、_currentHookStatenullになります。
この処理は _useを呼び出す順番に依存 するため、useメソッドを呼び出す順番は一定である必要があります。

上記の説明を踏まえた上で、_useメソッド周辺のコードを抜き出すと、次のようになります。

mixin HookElement on ComponentElement {
  _Entry<HookState<Object?, Hook<Object?>>>? _currentHookState;
  final _hooks = LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>();

  
  Widget build() {
    // Check whether we can cancel the rebuild (caused by HookState.mayNeedRebuild).
    final mustRebuild = _isOptionalRebuild != true ||
        _shouldRebuildQueue.any((cb) => cb.value());

    _isOptionalRebuild = null;
    _shouldRebuildQueue.clear();

    if (!mustRebuild) {
      return _buildCache!;
    }

    _currentHookState = _hooks.isEmpty ? null : _hooks.first;
    HookElement._currentHookElement = this;
    try {
      _buildCache = super.build();
    } finally {
      _isOptionalRebuild = null;
      _unmountAllRemainingHooks();
      HookElement._currentHookElement = null;
      if (_needDispose != null && _needDispose!.isNotEmpty) {
        for (_Entry<HookState<dynamic, Hook<dynamic>>>? toDispose =
                _needDispose!.last;
            toDispose != null;
            toDispose = toDispose.previous) {
          toDispose.value.dispose();
        }
        _needDispose = null;
      }
    }

    return _buildCache!;
  }

  R _use<R>(Hook<R> hook) {
    /// At the end of the hooks list
    if (_currentHookState == null) {
      _appendHook(hook);
    } else if (hook.runtimeType != _currentHookState!.value.hook.runtimeType) {
      final previousHookType = _currentHookState!.value.hook.runtimeType;
      _unmountAllRemainingHooks();
      throw StateError('''
Type mismatch between hooks:
- previous hook: $previousHookType
- new hook: ${hook.runtimeType}
''');
    } 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);
      }
    }

    final result = _currentHookState!.value.build(this) as R;
    _currentHookState = _currentHookState!.next;
    return result;
  }

  HookState<R, Hook<R>> _createHookState<R>(Hook<R> hook) {
    final state = hook.createState()
      .._element = this
      .._hook = hook
      ..initHook();

    return state;
  }

  void _appendHook<R>(Hook<R> hook) {
    final result = _createHookState<R>(hook);
    _currentHookState = _Entry(result);
    _hooks.add(_currentHookState!);
  }
}

_createHookStateに関しては、後ほどHookStateの処理を見る中で詳しく見ていきます。今は、メソッドやプロパティがHookElement内でアクセスされていることだけ、把握しておいてください。

処理の中で重要なのは、次のif文でしょう。筆者が把握した範囲で、コメントを追加します。

if (_currentHookState == null) {
  // 正常系、hookの追加
} else if (hook.runtimeType != _currentHookState!.value.hook.runtimeType) {
  // 異常系、_hooksに追加されているhookとアクセスしているhookの型が異なる
} else if (hook != _currentHookState!.value.hook) {
  // 正常系、hookの更新
  final previousHook = _currentHookState!.value.hook;
  if (Hook.shouldPreserveState(previousHook, hook)) {
    // hookを再利用する場合
  } else {
    // hookを破棄する場合
  }
}

// _currentHookStateを_hooksから復旧した上で、HookState.buildの呼び出し
final result = _currentHookState!.value.build(this) as R;
return result;

useHookWidgetStatefulHookWidgetbuildメソッド内で呼び出されることが前提となっています。buildメソッドはWidgetのリビルドが走るたびに呼び出されるため、何度_useメソッドが呼び出されるかは確定していません。上記の実装は、何度呼び出されても正常に動作するよう設計されています。

unmountとdeactivate

HookElementは、unmountdeactivateをoverrideしています。これらのメソッドは、HookStatedisposedeactivateを呼び出しています。

mixin HookElement on ComponentElement {
  final _hooks = LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>();

  
  void unmount() {
    super.unmount();
    if (_hooks.isNotEmpty) {
      for (_Entry<HookState<dynamic, Hook<dynamic>>>? hook = _hooks.last;
          hook != null;
          hook = hook.previous) {
        try {
          hook.value.dispose();
        } catch (exception, stack) {
          FlutterError.reportError(
            FlutterErrorDetails(
              exception: exception,
              stack: stack,
              library: 'hooks library',
              context: DiagnosticsNode.message(
                'while disposing ${hook.runtimeType}',
              ),
            ),
          );
        }
      }
    }
  }

  
  void deactivate() {
    for (final hook in _hooks) {
      try {
        hook.value.deactivate();
      } catch (exception, stack) {
        FlutterError.reportError(
          FlutterErrorDetails(
            exception: exception,
            stack: stack,
            library: 'hooks library',
            context: DiagnosticsNode.message(
              'while deactivating ${hook.runtimeType}',
            ),
          ),
        );
      }
    }
    super.deactivate();
  }
}

unmountがListの後ろからアクセスしているので、ちょっとだけ見慣れないfor文になっていますが、順繰りにdiposeを呼び出しています。deactivateは、Listの要素を順繰りにdeactivateを呼び出しています。
コードを読めばわかる通りなので、特にコメントはありません。

HookWidgetとStatefulHookWidget

hookを利用するには、上記のHookElementへのアクセスが必要です。HookWidgetStatefulHookWidgetは、HookElementをmixinしたElementcreateElementで返却するように実装されています。

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/framework.dart#L579-L615

この実装により、HookElement_hooksにて任意のHookStateを保持できるようになります。生成や破棄がHookElementで管理されるため、HookStateは通常のElementと同じようにElementによってStateが管理されることになります。


flutter_hooksはFlutterのWidgetにHookElementを追加し、HookStateを管理できるようにしたものです。それぞれのHookStateは対応するHookElementの参照を持っており、StatefulElementの実装に近い形です。

https://github.com/flutter/flutter/blob/3.24.0/packages/flutter/lib/src/widgets/framework.dart#L5681-L5695

https://github.com/flutter/flutter/blob/3.24.0/packages/flutter/lib/src/widgets/framework.dart#L5697-L5929

Elementの振る舞いについては、次の記事が参考になります。該当箇所を確認したい方は、「初期のElementツリーが構築されるまでの流れ」を参照してください。

https://medium.com/flutter-jp/dive-into-flutter-4add38741d07

良い見方をすれば、HookWidgetは「StatelessElementに対し、非常に少ない実装を足すことで、StatefulElement相当の振る舞いを実現した」ものと言えます。他の見方をすると、StatefulElementに対しては、Stateの管理にHookStateの管理を追加したものと言えます。[3]

HookState

HookStateは、任意のhookを記述する際に、Hook.createStateが返却するクラスの継承元になります。文章で読むとわかりにくいので、適当なhookを見てみましょう。

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/debounced.dart

useDebounceduse(_DebouncedHook(...))を呼び出すことで利用できます。_DebouncedHookHookを継承しており、createStateを実装しています。このcreateStateHookStateを継承した_DebouncedHookStateを返却しています。
StatefulWidgetStateを継承したクラスを、createStateで返却するのと同じです。この命名にする必要はないと思われますが、Flutterエンジニアに直感的に理解してもらいやすくなるので、このような命名になっていると筆者は考えています。

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/framework.dart#L219-L324

HookStateは、初回のアクセス時にcreateStateinitHookを実行します。これらはHookElement_useメソッド内で呼び出されるため、HookWidgetStatefulHookWidgetbuildメソッド内で呼び出されることになります。

HookState<R, Hook<R>> _createHookState<R>(Hook<R> hook) {
  final state = hook.createState()
    .._element = this
    .._hook = hook
    ..initHook();

  return state;
}

StatefulElementの場合、Elementの生成時にcreateStateが実行されます。その後、Elementmountされた際に_firstBuildメソッドが呼び出され、initStateの処理が実行されます。なお、StatelessElementの場合には、ComponentElementの実装をoverrideしていないため、initStateの処理は実行されません。
Elementmountについては、先ほどの初期のElementツリーが構築されるまでの流れを参照してください。

https://github.com/flutter/flutter/blob/3.24.0/packages/flutter/lib/src/widgets/framework.dart#L5588-L5600

https://github.com/flutter/flutter/blob/3.24.0/packages/flutter/lib/src/widgets/framework.dart#L5698-L5726

HookStateinitHookbuildは、StateinitStatebuildに相当するものです。実装を見比べてみると、ある意味当然ではありますが、HookStateStatefulStateに比べ実行が遅延されます。これはflutter_hooksが3rd party libraryであるため、致し方のないことだと言えます。

useState

useStateはflutter_hooksの中でも、特に利用されるhookです。一方で管理する値が更新された際、buildを呼び出す必要があるhookでもあります。
HookStateの理解を深めるためにも、useStateの実装を見てみましょう。

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/primitives.dart#L267-L301

useStateメソッドはuseメソッドを呼び出しています。useの引数にはHookを継承した_StateHookが渡されます。字面はややこしいのですが、この_StateHookStatefulWidgetStatefulWidgetクラスに相当するものです。
_StateHook.createState()HookStateを継承した_StateHookStateを返却します。この_StateHookStateStatefulWidgetStateに相当するものです。先の_createHookStateメソッドにて、このcreateStateメソッドが呼び出され、HookElement._hooksに保持されます。

結果、見るべきは次のコードです。

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>';
}

ここで注意が必要なのは、setStateです。HookStateStateではないため、厳密には(我々がパッとイメージする)メソッドを持っていません。HookStateのコードを見てみましょう。

https://github.com/rrousselGit/flutter_hooks/blob/flutter_hooks-v0.21.0/packages/flutter_hooks/lib/src/framework.dart#L306-L313

引数で取ったfunctionを実行したのちに、_isOptionalRebuildfalseを追加します。この処理は、直前のキャッシュを利用せず、新規にbuildが実行されます。なおHookState.markMayNeedRebuildを呼び出すhookが存在する場合には、この限りではありません。[4]

HookState.setStateにてmarkNeedsBuildを呼び出すのは、StatesetStateと同じです。どちらもComponentElementmarkNeedsBuildを呼び出すため、ようやく通常のWidgetに関する理解と話がつながります。

https://github.com/flutter/flutter/blob/3.24.0/packages/flutter/lib/src/widgets/framework.dart#L1163-L1224

ValueNotifierはsetされた値が同一でない場合、listenerを呼び出す仕組みになっています。結果、useState<T>で作り出したValueNotifier<T>の値を更新すると、Widgetのリビルドが走りUIに値が反映されます。

まとめ

flutter_hooksは、Flutterの仕組みを理解した上で、ComponentElementをhook用に拡張するライブラリです。なお、hooks_riverpodConsumerStatefulWidgetなどにHookElementをmixinすることで実現されています。
実装を追ってみると、HookElementを理解することで、HookStateを適切に扱えるようになります。HookStateはベーシックな利用をする限りは、Stateの知識があれば問題ありません。しかし、細かな調整を試みる場合には、HookElementの理解が必要になります。

おまけ

flutter_hooksの実装を知っておくと、次のIssueが理解しやすくなります。議論がオープンなのは嬉しいですね。

https://github.com/flutter/flutter/issues/25280

脚注
  1. StateではなくElementのAPIを触っているため、APIのドキュメント頼りで読みます。 ↩︎

  2. didChangeDependenciesStatefulHookWidgetで利用されます。 ↩︎

  3. ConsumerHookWidgetStatefulWidgetを継承しているので、実はこのパターンを利用しているケースは多数あります。 ↩︎

  4. これはHookState.markMayNeedRebuildが強力すぎると思います ↩︎

GitHubで編集を提案

Discussion