【Flutter】ウィジェットのライフサイクルを管理するFlutter Hooksの概要と基本的なフックの使い方まとめ

Flutterでウィジェットのライフサイクルを管理するFlutter Hooksの概要と基本的なフックの使い方についてまとめました。

Flutter Hook 0.20.5

Flutter Hooksとは?

Flutter HooksはFlutterのWidgetのライフサイクルを管理するためのライブラリです。
その名の通り、ライフサイクルを管理するための「フック」がいくつも用意されています。

pub.dev

さて、ライフサイクル管理というと抽象的ですが、具体的には以下のようなメリットが得られるフックが用意されています。

  • 状態を持つWidgetを簡単に書ける useState フック
  • Widgetの初期化、破棄の処理を簡単にかける useEffect フック
  • 重い処理をキャッシュしてビルド時に走らないようにできる useMemoized フック
  • 値が変化した時だけ任意の処理を走らせられる useValueChanged フック
  • 値が変化しても再ビルドしない useRef フック
  • buildContextをどこからでも取得できる buildContext フック

本記事ではこれら基本的なフックの使い方についてまとめます。

なおインストールについては詳しくは説明しませんが、他のパッケージと同じように以下で行えます。

flutter pub add flutter_hooks

useState: 状態を持つWidgetを簡単に書ける

さてそれではまず、useState フックを使ってみます、

これを使うと、本来 Stateful Widget が必要な状態を持つWidgetを簡潔に書くことができます。
例として、下図のようにタップすると数値が1ずつ増加する簡単なアプリを作ります。

タップするとインクリメント

これをStateful Widgetを使って実装すると、以下のようになります。

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

// Stateful Widgetを使った場合の実装
class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  HomePageState createState() => HomePageState();
}

class HomePageState extends State<HomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _incrementCounter,
      child: Text('$_counter'),
    );
  }
}

これに対して、useState を使うと以下のように実装できます。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

// Flutter HooksのuseStateを使った実装
class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
      // useStateを使ってint型の変数を作成・購読
    final counter = useState(0);

    return GestureDetector(
      onTap: () => counter.value++, // 値を変えると自動的にHomePageがリビルドされる
      child: Text(counter.value.toString()),
    );
  }
}

Stateを定義することなく、状態を持つWidgetを簡単に作れました。

useEffect: 初期化・破棄の処理を簡単に書ける

次に初期化・破棄の処理を簡単に書ける useEffect フックを使ってみます。
以下は、前節のコードに初期化と破棄の処理を加えたものです。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useState(0);

    // useEffectフックで初期化・破棄の処理を書く
    useEffect(() {
      // Widgetがビルドされたときの処理をここに書く
      print('build by useEffect');

      return () {
        // Widgetが破棄されるとき処理を返す(不要な場合はnull)
        print('dispose by useEffect');
      };
    });

    return GestureDetector(
      onTap: () => counter.value++,
      child: Text(counter.value.toString()),
    );
  }
}

HomePage Widgetがビルドされた時と破棄される時の処理の処理を記述できました。
実行すると、タップするたびに useState による Widget のビルドが行われ、その結果として破棄と初期化の処理が繰り返し呼ばれることを確認できます。

また、useEffectの第二引数を渡すと、「useEffectが呼び出された時にこの値のいずれかが変わっていた時のみ処理を行う」という挙動になります。
以下の例では、counterに加えてcounter2という状態を定義し、それぞれが変化したらWidget自体は再ビルドされるが、useEffectの処理がよばれるのはcounter2の値が変化した時のみになります。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useState(0);
    final counter2 = useState(0); // counter2を追加

    useEffect(() {
      print('build by useEffect');

      return () {
        print('dispose by useEffect');
      };
    }, [counter2.value]); // counter2の値が変わっているときだけ処理

    return GestureDetector(
      onTapDown: (_) => counter.value++, // 押下したらcounterをインクリメント
      onTapUp: (_) => counter2.value++, // 離上したらcounter2をインクリメント
      child: Text(counter.value.toString()),
    );
  }
}

これを利用して、第二引数に空の配列を渡すと(その値は変化することがないので)Widgetのライフサイクルに関わらず、最初の一回だけ初期化処理を行うということもできます。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useState(0);

    useEffect(() {
      print('build by useEffect');

      return () {
        print('dispose by useEffect');
      };
    }, []); // 第二引数に空の配列を渡す

    return GestureDetector(
      onTap: () => counter.value++,
      child: Text(counter.value.toString()),
    );
  }
}

useMemoized: 重い処理をキャッシュしてビルド時に走らないようにできる

useMemoizedを使うと任意のインスタンスをキャッシュし、Widget が再ビルドされても同じインスタンスを使い回すことができます。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useState(0);

    // timeをキャッシュするので再ビルドされても毎回同じ値が表示される
    final time = useMemoized(() => DateTime.now());
    print(time);

    return GestureDetector(
      onTap: () => counter.value++,
      child: Text(counter.value.toString()),
    );
  }
}

第二引数を与えると、その値が変わっていたらインスタンスを作り直すことができます。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useState(0);

    // 第二引数の値が変わっていたらインスタンスを作り直す
    // = この例では毎回作り直される
    final time = useMemoized(() => DateTime.now(), [counter.value]);
    print(time);

    return GestureDetector(
      onTap: () => counter.value++,
      child: Text(counter.value.toString()),
    );
  }
}

useValueChanged: 値が変化した時だけ任意の処理を走らせる

useValueChanged フックを使うと値が変化した時だけ任意の処理を走らせることができます。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useState(0);

    // 値が変化した時の処理
    useValueChanged(
      counter.value,
      (oldValue, _) => print('time has changed: $oldValue -> ${counter.value}'),
    );

    return GestureDetector(
      onTap: () => counter.value++,
      child: Text(counter.value.toString()),
    );
  }
}

useRef: 値が変化しても再ビルドしない

値が変化しても再ビルドを走らせたくない場合には useRef フックを使います。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(const MaterialApp(home: HomePage()));
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useRef(0); // 値が変化しても再ビルドが走らない

    return GestureDetector(
      onTap: () {
        counter.value++;
        print('counter.value: ${counter.value}'); // 今の値を出力
      },
      child: Text(counter.value.toString()),
    );
  }
}

これを実行して画面をタップすると、値はインクリメントされて結果がコンソールに出力されるのに対して、Widgetの再ビルドは行われないためGUIとして描画されている数値は変化しないことが確認できます。

useContext: どこからでもBuildContextを取得できる

useContextを使うと、BuildContextの参照を引き回さなくても取得できるようになります。

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(
    MaterialApp(
      theme: ThemeData(primaryColor: Colors.blue),
      home: const HomePage(),
    ),
  );
}

class HomePage extends HookWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    print('build');
    final counter = useState(0);

    return GestureDetector(
      onTap: () {
        counter.value++;
        print('counter.value: ${counter.value}');
      },
      child: _createText(counter.value.toString()),
    );
  }

  Text _createText(String value) {
    // useContextを使うといちいちContextを引き回さなくても取得できる
    final context = useContext();
    return Text(
      value,
      style: TextStyle(color: Theme.of(context).primaryColor),
    );
  }
}

その他のフックについて

上記で紹介したフックの他に、例えば以下のようにAnimationControllerの生成と破棄の処理を行うフックなど、特定の用途のためのフックが多数用意されています。

Widget build(BuildContext context) {
  // AnimationControllerを取得できる(生成と破棄はフックがやってくれる)
    final controller = useAnimationController();
    final controller2 = useAnimationController();
    return ...
}

本記事ではこれらについては解説しませんので、必要に応じて公式ドキュメントを参照してください。

pub.dev

また、自作のカスタムフックを自由に作ることもできます。
これらを用意することで、異なる Widget 間でフックを使い回して実装の共通化を行うことができます。

参考

pub.dev