はじめに
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.