🦤

君たちはuseEffectとどう生きるか

2023/07/24に公開

はじめに

状態管理や副作用など少ないコードで多機能な挙動を実現できて、便利なflutter_hooksですが、しっかり挙動を理解できていないと思わぬバグに繋がる恐れがあります。

今回はflutter_hooksの中でもuseEffectの挙動にフォーカスします。
実際のコードから結果がどうなるか予想しながら読むと自分の理解を確認でき、少しでも参考になれば幸いです。

それでは参ります。

君たちはuseEffectとどう生きるか

内容

サンプルコードの実行環境

名称 バージョン
Flutter 3.10.4
Dart 3.0.3
flutter_hooks 0.19.0

useRefとObjectRef.valueをKeyに

useRefで countというObjectRefを定義してそのvalueをuseEffectのKeyに指定した場合です。
useEffectのラムダ式ではただ print()しているだけです。

FilledButtononPressed()が呼ばれたときに countの値を更新しています。

このとき、結果はどうなるでしょうか?

サンプルコード
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    final count = useRef(0);

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        print('count = ${count.value}');
      },
      [count.value],
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: Center(
        child: FilledButton(
          onPressed: () => count.value++,
          child: const Text('count++'),
        ),
      ),
    );
  }
}

結果

結果は以下のようになります。

結果
I/flutter (20300): count = 0

初回のビルド時に一度だけ呼ばれるだけで、何回ボタンを押下してもuseEffect内の print()が呼ばれることはありません。

useRefとObjectRefをKeyに

useRefで countというObjectRefを定義してそのObjectRefをそのままuseEffectのKeyに指定した場合です。
useEffectのラムダ式ではただ print()しているだけです。

FilledButtononPressed()が呼ばれたときに countの値を更新しています。

このとき、結果はどうなるでしょうか?

サンプルコード
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    final count = useRef(0);

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        print('count = ${count.value}');
      },
      [count],
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: Center(
        child: FilledButton(
          onPressed: () => count.value++,
          child: const Text('count++'),
        ),
      ),
    );
  }
}

結果

結果は以下のようになります。

結果
I/flutter (20300): count = 0

初回のビルド時に一度だけ呼ばれるだけで、何回ボタンを押下してもuseEffect内の print()が呼ばれることはありません。

useStateとValueNotifier.valueをKeyに

useStateで countというValueNotifierを定義してそのvalueをuseEffectのKeyに指定した場合です。
useEffectのラムダ式ではただ print()しているだけです。

FilledButtononPressed()が呼ばれたときに countの値を更新しています。

このとき、結果はどうなるでしょうか?

サンプルコード
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    final count = useState(0);

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        print('count = ${count.value}');
      },
      [count.value],
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: Center(
        child: FilledButton(
          onPressed: () => count.value++,
          child: const Text('count++'),
        ),
      ),
    );
  }
}

結果

結果は以下のようになります。

結果
I/flutter (20300): count = 0
I/flutter (20300): count = 1
I/flutter (20300): count = 2
I/flutter (20300): count = 3
I/flutter (20300): count = 4
I/flutter (20300): count = 5

初回のビルド時に I/flutter (20300): count = 0が出力されます。
それ以降は、ボタンを押下するたびにプラス1された値が I/flutter (20300): count = 1I/flutter (20300): count = 2と出力されます。

useStateとValueNotifierをKeyに

useStateで countというValueNotifierを定義してそのValueNotifierをそのままuseEffectのKeyに指定した場合です。
useEffectのラムダ式ではただ print()しているだけです。

FilledButtononPressed()が呼ばれたときに countの値を更新しています。

このとき、結果はどうなるでしょうか?

サンプルコード
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    final count = useState(0);

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        print('count = ${count.value}');
      },
      [count],
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: Center(
        child: FilledButton(
          onPressed: () => count.value++,
          child: const Text('count++'),
        ),
      ),
    );
  }
}

結果

結果は以下のようになります。

結果
I/flutter (20300): count = 0

初回のビルド時に一度だけ呼ばれるだけで、何回ボタンを押下してもuseEffect内の print()が呼ばれることはありません。
ValueNotifierのvalueをKeyに指定した場合と少し違うだけで、大きな挙動の違いなので注意が必要です。
ValueNotifierをKeyにすると、ボタン押下時にValueNotifierのvalueは更新されてその度にリビルドが発生しますが、ValueNotifierのインスタンス自体に変化はないため、このような挙動になります。

なにもしないvoid FunctionをKeyに

build()内で doSomething()というメソッドを定義して、そのvoid FunctionをuseEffectのKeyに指定して、useEffectのラムダ式で実行している場合です。
doSomething()では、ただ print()しているだけです。

このとき、結果はどうなるでしょうか?

サンプルコード
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    void doSomething() {
      print('doSomething');
    }

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        doSomething();
      },
      [doSomething],
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: const Text('Hello world'),
    );
  }
}

結果

結果は以下のようになります。

結果
I/flutter (23665): doSomething

初回のビルド時に一度だけ出力されます。

Page全体リビルドをかけてなにもしないvoid FunctionをKeyに

build()内で doSomething()というメソッドを定義して、そのvoid FunctionをuseEffectのKeyに指定して、useEffectのラムダ式で実行している場合です。
doSomething()では、ただ print()しているだけです。

また、useStateで countというValueNotifierを定義して FilledButtononPressed()が呼ばれたときに countの値を更新しています。

このとき、結果はどうなるでしょうか?

サンプルコード
sample.dart
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    final count = useState(0);

    void doSomething() {
      print('doSomething');
    }

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        doSomething();
      },
      [doSomething],
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: Column(
        children: [
          Text(count.value.toString()),
          FilledButton(
            onPressed: () => count.value++,
            child: const Text('count++'),
          ),
        ],
      ),
    );
  }
}

結果

結果は以下のようになります。

結果
I/flutter (25559): doSomething
I/flutter (25559): doSomething
I/flutter (25559): doSomething
I/flutter (25559): doSomething

ボタンを押下してuseStateのValueNotifierを更新してPage全体リビルドを書けるたびにuseEffectのラムダが実行され、 その中で実行している doSomething()内の print()が出力されます。

useEffectのラムダ式内で print('doSomething.hashCode = ${doSomething.hashCode}'); のようにすると確認できますが、毎回異なるハッシュ値が確認できます。

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        doSomething();
        print('doSomething.hashCode = ${doSomething.hashCode}');
      },
      [doSomething],
    );
結果
I/flutter (27021): doSomething
I/flutter (27021): doSomething.hashCode = 462925064
I/flutter (27021): doSomething
I/flutter (27021): doSomething.hashCode = 606372275
I/flutter (27021): doSomething
I/flutter (27021): doSomething.hashCode = 638055

ボタン押下時に、ValueNotifierのvalueが更新されてリビルド発生時に doSomething()が再生成されて、その変化を検知してuseEffectのラムダ式が再実行されています。

コンストラクタ引数に渡されたvoid FunctionをKeyにするとどうなるか

HomePagebuild()内で doSomething()というメソッドを定義して、そのvoid Functionを子の _Component に渡しています。
渡された void Function_Componentbuild()内の useEffectのラムダ式で実行されています。Keyにも onPressedを指定しています。

また、 _Component内ではuseStateで countというValueNotifierを定義して FilledButtononPressed()が呼ばれたときに countの値を更新しています。

このとき、結果はどうなるでしょうか?

サンプルコード
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    void doSomething() {
      print('doSomething');
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: _Component(onPressed: doSomething),
    );
  }
}

class _Component extends HookWidget {
  const _Component({
    Key? key,
    required this.onPressed,
  }) : super(key: key);

  final VoidCallback onPressed;

  
  Widget build(BuildContext context) {
    final count = useState(0);

    // ignore: body_might_complete_normally_nullable
    useEffect(() {
      onPressed();
    }, [onPressed]);

    return Column(
      children: [
        Text(count.value.toString()),
        FilledButton(
          onPressed: () => count.value++,
          child: const Text('count++'),
        ),
      ],
    );
  }
}

結果

結果は以下のようになります。

結果
結果
I/flutter (27344): doSomething

初回のビルド時に一度だけ出力されます。それ以降は何度 _Component内の FilledButtononPressedを実行してもなにも出力されません。

ValueNotifiet.valueをuseEffectのKeyに指定して、ラムダ式で更新してるケース

useStateで countというValueNotifierを定義してそのvalueをuseEffectのKeyに指定した場合です。また、useEffectのラムダ式では countの値を更新しています。

さらに、FilledButtononPressed()が呼ばれたときに countの値を更新しています。

このとき、結果はどうなるでしょうか?

サンプルコード
class HomePage extends HookWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    final count = useState(0);

    useEffect(
      // ignore: body_might_complete_normally_nullable
      () {
        print('update before count.value = ${count.value}');
        count.value++;
        print('update after count.value = ${count.value}');
      },
      [count.value],
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useEffect Sample'),
      ),
      body: Column(
        children: [
          Text(count.value.toString()),
          FilledButton(
            onPressed: () => count.value++,
            child: const Text('count++'),
          ),
        ],
      ),
    );
  }
}

結果

結果は以下のようになります。

結果
I/flutter (11856): update before count.value = 0
I/flutter (11856): update after count.value = 1
// ボタン押下
I/flutter (11856): update before count.value = 2
I/flutter (11856): update after count.value = 3
// ボタン押下
I/flutter (11856): update before count.value = 4
I/flutter (11856): update after count.value = 5

初回ビルド時に、 I/flutter (11856): update before count.value = 0が出力されて、useEffect内の count.value++;が実行されるため、そのあとに I/flutter (11856): update after count.value = 1 が出力されます。
ですが、ここでリビルドは発生せず無限ループのような挙動にはなりません。

ボタン押下時に count.value++;が実行されてリビルドが発生し、useEffectのラムダ式がまた実行される流れになっています。

おわりに

さて、いくつ正解できたでしょうか。
詳細まで理解していないと、useEffectのラムダ式で重要なロジックを実行させる場合意図しない挙動になる場合があります。

これは恐ろしいことです。

これを期にこれまで書いたuseEffectのコードを見返すなどすると、もしかしたらまだ気づいてはいなかったバグに気づくことができるかもしれません。

参考

Discussion