はじめに
Flutter HooksとはReact HooksのFlutter版です。
React Hooksについてはこちらの記事がわかりやすいです。
5分でわかるReact Hooks
現在、Hooks自体が注目されているのと同時に、RiverpodというProviderの上位版が登場し、このRiverpodがFlutter Hooksとの併用を推奨していることもあり、より注目を浴びているようです。
本記事ではFlutter Hooksのサンプルコードをベースに、Flutter HooksのuseXXXまわりの使い方についてまとめます。
※ Flutter側とHooksについて、いろいろと議論されているので、今後どうなるのかは不透明ですが、ここでは触れないでおきます。
https://github.com/flutter/flutter/issues/51752
環境
flutter_hooks 0.13.2
Flutter 1.20.3
※2020/9/4時点の最新版
PrimitivesなHooks
useContext
HookWidgetのbuild内でBuildContextを取得できます。
仕組み
BuildContext useContext() {
  assert(
    HookElement._currentHookElement != null,
    '`useContext` can only be called from the build method of HookWidget',
  );
  return HookElement._currentHookElement;
}
使い方
class SampleWidget extends HookWidget {
  @override
  Widget build(BuildContext context) {
    return Container(child: _buildBody());
  }
  Widget _buildBody() {
    // [BuildContext]を引数でバケツリレーしなくてもよくなる。ただし、buildの外で`useContext`を実行しても[BuildContext]は取得できません
    final context = useContext();
    // 途中略
    
    return SomeWidget();
  }
}
useState
ValueNotifierをラップして使やすくしてくれます。
HookWidgetの中でuseState(初期値)と書くだけでValueNotifierを取得できます。
コードは以下のようになっています。
仕組み
// 引数は初期値
ValueNotifier<T> useState<T>([T initialData]) {
  return use(_StateHook(initialData: initialData));
}
詳細コード
class _StateHook<T> extends Hook<ValueNotifier<T>> {
  const _StateHook({this.initialData});
  final T initialData;
  @override
  _StateHookState<T> createState() => _StateHookState();
}
class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
  // 実態としてValueNotifierを持っています
  ValueNotifier<T> _state;
  @override
  void initHook() {
    super.initHook();
    // 初めてcallされたときにinitialDataを使って初期化します
    _state = ValueNotifier(hook.initialData)..addListener(_listener);
  }
  @override
  void dispose() {
    // disposeまで担保してくれます
    _state.dispose();
  }
  @override
  ValueNotifier<T> build(BuildContext context) {
    return _state;
  }
  void _listener() {
    setState(() {});
  }
}
引数の[T initialData]の部分は、オプショナルな位置的パラメータです。(カッコ[]は配列ではありません)
使い方
使い方はuseState(0)のように書くだけです。
引数の初期値は省略も可能です。省略する場合はuseState<int>()のように型を指定する必要があります。また、その場合の初期値はnullになるため、扱いに注意してください。
また、初期値による更新通知は発生しません。
class UseStateExample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final counter = useState(0);
    return Scaffold(
      appBar: AppBar(title: const Text('useState example')),
      body: Center(
        // Read the current value from the counter
        child: Text('Button tapped ${counter.value} times'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => counter.value++,
        child: const Icon(Icons.add),
      ),
    );
  }
}
useEffect
初期化処理など一度のみ実行したい場合や、開放処理を書きたい場合に便利な仕組みです。
仕組み
void useEffect(Dispose Function() effect, [List<Object> keys]) {
  use(_EffectHook(effect, keys));
}
keysが指定されていれば、effectは一度のみ呼ばれます。そのため、一度だけ行いたい初期化処理などに有用です。
逆に、keysが指定されていない場合は毎回effectが同期的に呼ばれます。
また、keysに変更があった場合はeffectでreturnした関数が実行され、もう一度effectが呼ばれます。
詳細コード
typedef Dispose = void Function();
class _EffectHook extends Hook<void> {
  const _EffectHook(this.effect, [List<Object> keys])
      : assert(effect != null, 'effect cannot be null'),
        super(keys: keys);
  
  // Widgetがdisposeのタイミングで呼ばれます
  final Dispose Function() effect;
  @override
  _EffectHookState createState() => _EffectHookState();
}
class _EffectHookState extends HookState<void, _EffectHook> {
  Dispose disposer;
  @override
  void initHook() {
    super.initHook();
    scheduleEffect();
  }
  @override
  void didUpdateHook(_EffectHook oldHook) {
    super.didUpdateHook(oldHook);
    // [keys]がnullだと毎回scheduleEffectが実行されます
    if (hook.keys == null) {
      if (disposer != null) {
        disposer();
      }
      scheduleEffect();
    }
  }
  @override
  void build(BuildContext context) {}
  @override
  void dispose() {
    // [effect]で[Dispose](関数)を返却していれば、自動で開放してくれます
    if (disposer != null) {
      disposer();
    }
  }
  void scheduleEffect() {
    disposer = hook.effect();
  }
}
使い方
class UseEffectSample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final store = useMemoized(() => MyStore());
    useEffect(() {
      // 初期表示時にデータのロードを実行
      store.loadData();
      // 関数(Function())を返却しておくと、Widgetのライフサイクルに合わせてWidgetのdisposeのタイミングで関数を実行してくれます(不要であればnullでOK)
      return store.dispose;
    },
      // [keys]は空配列でも問題ない
      const []);
    return SomeWidget();
  }
}
ここで注意すべきは、useEffectの戻り値は関数のため、store.dispose()ではなく、store.disposeである必要があります。少し崩した書き方をすると、以下のような形にすると理解しやすいでしょうか。
useEffect(() {
  // 初期化処理
  
  return () {
    // 開放処理
  };
});
useMemoized
値をキャッシュしてくれるため、API通信などで取得したデータの管理に便利です。
具体的にはStreamやFutureの管理を想定しています。
仕組み
T useMemoized<T>(
     T Function() valueBuilder,
     [List<Object> keys = const <dynamic>[]]) {
  return use(_MemoizedHook(
    valueBuilder,
    keys: keys,
  ));
}
初めてcallされると一度だけvalueBuilderが呼ばれ、その値をキャッシュします。
以降、HookWidgetがリビルドされてもキャッシュされた値を返却します。この時、valueBuilderは呼ばれません。
ただし、keys(オプショナルな第二引数)が変更された場合はvalueBuilderが再度呼ばれて新しいインスタンスが生成されます。keysはデフォルト初期値がconst <dynamic>[]となっているため、指定しなくてもそれっぽくは動くようになっています。
詳細コード
class _MemoizedHook<T> extends Hook<T> {
  const _MemoizedHook(
    this.valueBuilder, {
    List<Object> keys = const <dynamic>[],
  })  : assert(valueBuilder != null, 'valueBuilder cannot be null'),
        assert(keys != null, 'keys cannot be null'),
        // keysは親のHookに渡されます
        super(keys: keys);
  final T Function() valueBuilder;
  @override
  _MemoizedHookState<T> createState() => _MemoizedHookState<T>();
}
class _MemoizedHookState<T> extends HookState<T, _MemoizedHook<T>> {
  T value;
  @override
  void initHook() {
    super.initHook();
    // 初めてcallされたタイミングでvalueBuilderが呼ばれる
    value = hook.valueBuilder();
  }
  @override
  T build(BuildContext context) {
    return value;
  }
}
keysについて
useMemorizedで指定するkeysは、Objectの配列になっています。KeyクラスではなくObjectの配列なので、APIリクエストの場合などはリクエストパラメータなどをセットすると良いかもしれません。
また、これは指定すると親クラスであるHookへと渡されます。そこの説明にはこう記載されています。
  /// A list of objects that specify if a [HookState] should be reused or a new one should be created.
  ///
  /// When a new [Hook] is created, the framework checks if keys matches using [Hook.shouldPreserveState].
  /// If they don't, the previously created [HookState] is disposed, and a new one is created
  /// using [Hook.createState], followed by [HookState.initHook].
  final List<Object> keys;
使い方
次のuseFuture/useStreamと併用することが多いので、そちらでまとめて説明します。
dart:async関連Hooks
useFuture / useStream
FutureやStreamを扱うもので、FutureBuilderやStreamBuilderを簡単にかけるようになります。
仕組み
useFutureを例にしますが、useStreamもほぼ同等の内容です。
// [preserveState]をtrueにすると、Futureのインスタンスが変わった場合でも現在の値を維持することができます。(デフォルトtrue)
AsyncSnapshot<T> useFuture<T>(Future<T> future,
    {T initialData, bool preserveState = true}) {
  return use(_FutureHook(future,
      initialData: initialData, preserveState: preserveState));
}
詳細コードおよび`preserveState`パラメータの補足
class _FutureHook<T> extends Hook<AsyncSnapshot<T>> {
  const _FutureHook(this.future, {this.initialData, this.preserveState = true});
  final Future<T> future;
  final bool preserveState;
  final T initialData;
  @override
  _FutureStateHook<T> createState() => _FutureStateHook<T>();
}
class _FutureStateHook<T> extends HookState<AsyncSnapshot<T>, _FutureHook<T>> {
  Object _activeCallbackIdentity;
  AsyncSnapshot<T> _snapshot;
  @override
  void initHook() {
    super.initHook();
    _snapshot =
        AsyncSnapshot<T>.withData(ConnectionState.none, hook.initialData);
    _subscribe();
  }
  @override
  void didUpdateHook(_FutureHook<T> oldHook) {
    super.didUpdateHook(oldHook);
    if (oldHook.future != hook.future) {
      if (_activeCallbackIdentity != null) {
        _unsubscribe();
        // [preserveState]はここで使われます(後述)
        if (hook.preserveState) {
          _snapshot = _snapshot.inState(ConnectionState.none);
        } else {
          _snapshot =
              AsyncSnapshot<T>.withData(ConnectionState.none, hook.initialData);
        }
      }
      _subscribe();
    }
  }
  @override
  void dispose() {
    _unsubscribe();
  }
  void _subscribe() {
    if (hook.future != null) {
      final callbackIdentity = Object();
      _activeCallbackIdentity = callbackIdentity;
      hook.future.then<void>((data) {
        if (_activeCallbackIdentity == callbackIdentity) {
          setState(() {
            _snapshot = AsyncSnapshot<T>.withData(ConnectionState.done, data);
          });
        }
      }, onError: (dynamic error) {
        if (_activeCallbackIdentity == callbackIdentity) {
          setState(() {
            _snapshot = AsyncSnapshot<T>.withError(ConnectionState.done, error);
          });
        }
      });
      _snapshot = _snapshot.inState(ConnectionState.waiting);
    }
  }
  void _unsubscribe() {
    _activeCallbackIdentity = null;
  }
  @override
  AsyncSnapshot<T> build(BuildContext context) {
    return _snapshot;
  }
}
preserveStateの使用箇所を抜き出すと以下のようになっています。
if (hook.preserveState) {
  _snapshot = _snapshot.inState(ConnectionState.none);
} else {
  _snapshot = AsyncSnapshot<T>.withData(ConnectionState.none, hook.initialData);
}
ここでAsyncSnapshotのinStateとwithDataの実態を使って書き換えると以下のようになります。(ビルドエラーになりますが、雰囲気だけつかんでください…)
if (hook.preserveState) {
  _snapshot = AsyncSnapshot<T>._(ConnectionState.none, data, error);
} else {
  _snapshot = AsyncSnapshot<T>._(ConnectionState.none, hook.initialData, null);
}
AsyncSnapshotを生成する際のデータとエラー値に差分があり、前者は現在の状態を複製し、後者は初期値を使って状態をクリアしていることがわかります。
使い方
package_infoを使ってアプリ情報を取得する場合を例にとってみます。
まず、Hooksを使わずに素直に書くとこうなります。
class NotUseHooksSample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: PackageInfo.fromPlatform(),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return Text('appName = ${snapshot.data.appName}');
        } else {
          return Container();
        }
      },
    );
  }
}
これをHooksを使うとネストが浅くなり、シンプルになることがわかると思います。
class UseFutureSample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final packageInfo = useMemoized(PackageInfo.fromPlatform);
    final snapshot = useFuture(packageInfo);
    if (snapshot.hasData) {
      return Text('appName = ${snapshot.data.appName}');
    } else {
      return Container();
    }
  }
}
また、useStreamControllerというものもあり、これは自動でdisposeしてくれるStreamControllerを取得できるものです。
Hooks Riverpod
おまけです。
Flutter Hooksではありませんが、Hooks Riverpodでよく使われるuseProviderも紹介します。
useProvider
T useProvider<T>(ProviderListenable<T> provider) {
  final container = ProviderScope.containerOf(useContext());
  return use(_ProviderHook<T>(container, provider));
}
詳細コード
class _ProviderHook<T> extends Hook<T> {
  const _ProviderHook(this._container, this._providerListenable);
  final ProviderContainer _container;
  final ProviderListenable<T> _providerListenable;
  @override
  _ProviderHookState<T> createState() => _ProviderHookState();
}
class _ProviderHookState<T> extends HookState<T, _ProviderHook<T>> {
  ProviderSubscription<T> _link;
  @override
  void initHook() {
    super.initHook();
    _listen();
  }
  void _listen() {
    _link?.close();
    // De-reference the providerListenable so that `is` promotes the type
    final providerListenable = hook._providerListenable;
    _link = hook._container.listen<T>(
      providerListenable,
      mayHaveChanged: (_) => markMayNeedRebuild(),
    );
  }
  @override
  bool shouldRebuild() => _link.flush();
  @override
  T build(BuildContext context) {
    return _link.read();
  }
  @override
  void didUpdateHook(_ProviderHook<T> oldHook) {
    super.didUpdateHook(oldHook);
    assert(
      oldHook._providerListenable.runtimeType ==
          hook._providerListenable.runtimeType,
      'The provider listened cannot change',
    );
    if (oldHook._container != hook._container) {
      _listen();
    } else if (_link is SelectorSubscription<dynamic, T>) {
      final link = _link as SelectorSubscription<dynamic, T>;
      assert(
        hook._providerListenable is ProviderSelector<dynamic, T>,
        'useProvider was updated from `useProvider(provider.select(...)) '
        'to useProvider(provider), which is unsupported',
      );
      if ((hook._providerListenable as ProviderSelector<dynamic, T>).provider !=
          (oldHook._providerListenable as ProviderSelector<dynamic, T>)
              .provider) {
        _listen();
      } else {
        // this will update _state
        link.updateSelector(hook._providerListenable);
      }
    } else if (oldHook._providerListenable != hook._providerListenable) {
      _listen();
    }
  }
  @override
  void dispose() {
    _link.close();
    super.dispose();
  }
}
使い方
RiverpodのSampleから拝借しました。
final helloWorldProvider = Provider((_) => 'Hello world');
class Example extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final value = useProvider(helloWorldProvider);
    return Text(value); // Hello world
  }
}
詳しい解説はここでは割愛させていただきますが、ここでいうProviderはRiverpodで定義されたもので、他にChangeNotifierProviderやStateNotifierProvider、StateProvider、StreamProviderなどが用意されています。BuildContextを使わずにアクセスができるので便利ですね。
他にも便利なものがいくつか用意されていますので、随時追記していきたいと思います。
参考記事
Flutter Hooks, say goodbye to StatefulWidget and reduce boilerplate code.