🪝

Riverpodがつらい人のためのflutter_hooks入門

2024/07/11に公開1

いまさら説明する必要のないRiverpod

RiverpodはFlutterにおいてもう無くてはならないといっても過言ではないライブラリで、様々なユースケースで使用されています。状態管理にRiverpod「を」採用しているプロジェクトも数多のようにあると思います。

ここで実際にRiverpodを使っていて、「思ったよりRiverpodってしんどくない…?」と思ったことはないでしょうか? 2年くらいずっと思っていました。

Riverpodには、一つ重要な特性があります。

AVOID using providers for local widget state.
Providers are designed to be for shared business state. They are not meant to be used for local widget state, such as for:

  • storing form state
  • currently selected item
  • animations
  • generally everything that Flutter deals with a "controller" (e.g. TextEditingController)

https://riverpod.dev/ja/docs/essentials/do_dont

そう、ローカルのウィジェットの状態を保持するのに、Providerを使用しないでくださいと書いてあります。RiverpodはこれひとつあればFlutterの状態管理ができるというものではなく、あくまでビジネスロジックやDI、ウィジェットにまたがる状態を管理にのみ適しているということです。

これを無視してローカルのウィジェットの状態管理もRiverpodで行おうとすると、失敗します

そこでflutter_hooks

そこで、flutter_hooksに改めて注目しました。Riverpodがつらい理由の大部分は、flutter_hooksで吸収できます。そこでflutter_hooksをあらためて入門してみました。

Controllerのdispose

まず、TextEditingControllerを使用する例を考えてみましょう。


final somethingProvider = Provider((ref) => "initial text");
final inputTextStateProvider = StateProvider<String>((ref) => "");

class SampleField extends ConsumerStatefulWidget {
  const SampleField({super.key});

  
  ConsumerState<MyHomePage> createState() => _SampleFieldState();
}

class _SampleFieldState extends ConsumerState<MyHomePage> {
  final controller = TextEditingController();

  
  void didChangeDependencies() {
    super.didChangeDependencies();
    controller.text = ref.read(somethingProvider);
    controller.addListener(
      () => ref.read(inputTextStateProvider.notifier).state = controller.text,
    );
  }

  
  void dispose() {
    super.dispose();
    controller.dispose();
  }

  
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
    );
  }
}

Riverpodで取得したとあるProviderの値を初期値にして、入力値が変更されたらProviderの値を変更する例を考えます。

  • Riverpodではdisposeはできないので正しくdisposeするにはひとつひとつこまめにdispose()しないといけない
  • Providerを初期化処理に使用しようとすると、initState()で行うことはできず、didChangeDependencies()が最初のタイミングとなってしまうので、ここでやらないといけない
    • didChangeDependenciesはウィジェットの引数が変わったときにも呼ばれるので、引数がある場合はさらに一度初期化されたかどうかなどをチェックしないといけなくて、そうすると今度は初期化フラグみたいなのが必要な場合すらあります

状態管理にRiverpodだけを使用していると、こうした点が辛くなります。

ここで、flutter_hooksを使用してみましょう。

class SampleField extends HookConsumerWidget {
  const SampleField({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final controller =
        useTextEditingController(text: ref.read(somethingProvider));
    useMemoized(() {
      controller.addListener(() {
        ref.read(inputTextStateProvider.notifier).state = controller.text;
      });      
    });

    return TextField(
      controller: controller,
      decoration: InputDecoration()
    );
  }
}

???????? ものすごく簡潔になりました。

  • disposeはflutter_hooksが自分でやってくれるので不要
  • build()時にはrefにアクセスできるので、わざわざdidChangeDependenciesをオーバーライドして…といったことをする必要がない
  • addListenerは1回だけ呼ぶ必要があるが、useMemoizedに第二引数を省略した場合、初回の1回だけ呼ばれる処理になる。そのためここでaddListenerをすればよい

同期的処理で済むが、ビルド時毎回は処理してほしくない

たとえば、FizzBuzz問題を解いてみましょう。ここで、Sliderの値を最大値として、最大値までをListViewで表示するとします。

FizzBuzzのリストを作ることは、同期的に十分処理できるが、ビルド時毎回処理するのはリソースの無駄遣いです

その点を考慮すると、StatefulWidgetで対応するには、onChangedで処理をする必要があります。


class Sample extends StatefulWidget {
  const Sample();

  
  State<Sample> createState() => SampleState();
}

class SampleState extends State<Sample> {
  late List<String> list = generateFizzBuzz();
  int max = 1;

  List<String> generateFizzBuzz() => [
        for (int i = 1; i < max; i++)
          if (i % 15 == 0)
            "FizzBuzz"
          else if (i % 5 == 0)
            "Buzz"
          else if (i % 3 == 0)
            "Fizz"
          else
            i.toString()
      ];

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        Slider(
          value: max.toDouble(),
          onChanged: (value) => setState(() {
            max = value.toInt();
            list = generateFizzBuzz();
          }),
          min: 1,
          max: 100,
        ),
        Expanded(
          child: ListView.builder(
            itemCount: list.length,
            itemBuilder: (context, index) => Text(list[index]),
          ),
        ),
      ],
    );
  }
}

この程度であれば、大してメリットが薄いかもしれませんが、maxを変えるたびに明示的にlistも変えないといけないので、他に変わるタイミングがあればそのときもまた値が同期するようにしないといけません。

generateFizzBuzz関数でsetStateしたらよくない?というのもありますが、タイミングによってはsetStateが例外を吐くときもあります。といったことを考えるとこのシンプルな例でも沼が見えます。

これであれば、Riverpodを使ってもよいかもしれませんが、それにしてもStateNotifierProviderとその値をwatchしてFizzBuzzのListを返すProviderの2つを作るのはちょっと手間です。

このような例でも、flutter_hooksを用いれば非常に簡潔になります。

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

  
  Widget build(BuildContext context) {
    final max = useState(1);
    final list = useMemoized(
      () => [
        for (int i = 1; i < max.value; i++)
          if (i % 15 == 0)
            "FizzBuzz"
          else if (i % 5 == 0)
            "Buzz"
          else if (i % 3 == 0)
            "Fizz"
          else
            i.toString()
      ],
      [max.value],
    );

    return Column(
      children: [
        Slider(
          value: max.value.toDouble(),
          onChanged: (value) => max.value = value.toInt(),
          min: 1,
          max: 100,
        ),
        Expanded(
          child: ListView.builder(
            itemCount: list.length,
            itemBuilder: (context, index) => Text(list[index]),
          ),
        ),
      ],
    );
  }
}
  • 変更可能(ミュータブル)な値と、計算から求まる値が明確になった
    • StatefulWidgetでは、両方ともローカル変数として宣言するため、どれがユーザーの操作などによって直接変えうる値なのか、それらをmapした値なのかが判別できず、また誤って本来後者を想定したような変数にそうでない値を代入したりできてしまいます。
  • 特定の値が変わったときにのみ再計算することが明確になった
    • 従来のStatefulWidgetでは、特定の値が変わったときにその値に応じて別の値を再計算するために、わざわざそちらも代入して…といったことをしていましたが、useMemoizedで依存関係を明示することで、変更箇所で両方変えるといったことをしなくてよくなりました。
  • 「やや重い」同期的処理を、簡潔に計算回数を減らすことができるようになった
    • 最初の1回の初期値を計算するという点でも、その値が使い回せるということです。

ほかのuseMemoizedの使い方

useMemoizedのメリットは、やや重たい初期処理の結果をキャッシュして、その値を別のコントローラーなどに渡す初期値としても使うことができるという点もあります。

final list = useMemoized(
  () => [
    for (int i = 1; i < max.value; i++)
      if (i % 15 == 0)
        "FizzBuzz"
      else if (i % 5 == 0)
        "Buzz"
      else if (i % 3 == 0)
        "Fizz"
      else
        i.toString()
  ].join(",")
);
final controller = useTextEditingController(text: list)

いままでinitStatedidChangeDependenciesなどで行っていた「同期的なやや重い」初期処理をbuild時にわたす引数として使う…ようなケースでも、その処理結果をキャッシュされることを利用して、別のAnimationControllerやTextEditingControllerの初期値に使用することもできます。

onTapなどで、少し複雑な処理をしながらローカルのステートを変更したい

ここで、直接入力可能なようにダイアログを表示し、そのダイアログの値を反映するとします。
この処理はやや複雑なので、onTapの非同期関数を定義しました。

class Sample extends StatefulWidget {
  const Sample();

  
  State<Sample> createState() => SampleState();
}

class SampleState extends State<Sample> {
  late List<String> list = generateFizzBuzz();
  int max = 1;

  List<String> generateFizzBuzz() => [
        for (int i = 1; i < max; i++)
          if (i % 15 == 0)
            "FizzBuzz"
          else if (i % 5 == 0)
            "Buzz"
          else if (i % 3 == 0)
            "Fizz"
          else
            i.toString()
      ];

  Future<void> onTap() async {
    final result = await showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        content: TextField(
          onSubmitted: (text) => Navigator.of(context).pop(text),
        ),
      ),
    );
    final newMaxValue = int.tryParse(result ?? "");
    if (newMaxValue == null) return;
    setState(() {
        max = newMaxValue;
        list = generateFizzBuzz();
    });
  }

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        Row(children: [
          Slider(
            value: max.toDouble(),
            onChanged: (value) => setState(() {
              max = value.toInt();
              list = generateFizzBuzz();
            }),
            min: 1,
            max: 100,
          ),
          ElevatedButton(
            onPressed: onTap,
            child: Text("show"),
          ),
        ]),
        Expanded(
          child: ListView.builder(
            itemCount: list.length,
            itemBuilder: (context, index) => Text(list[index]),
          ),
        ),
      ],
    );
  }
}

これをflutter_hooksで表現するとどうなるでしょうか? setState(() => max = newMaxValue);の一文のために、ValueNotifier<int>を引数としてさらに追加することになるでしょうか?

あるいは、buildの中にこのFuture<void> onTap() async{ ... を持ってくる?その場合、ビルド時に毎回関数を評価しないといけなくなり、ビルド時のコストが重くなってしまいます。

そこで登場するのがuseCallbackです。useCallbackuseMemoizedの関数限定版です。というより、単なるuseMemoizedそのものを使っていて、関数自体をキャッシュしてくれます。

この説明だけだとなんのことやらになってしまいますが、

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

  
  Widget build(BuildContext context) {
    final max = useState(1);
    final list = useMemoized(
      () => [
        for (int i = 1; i < max.value; i++)
          if (i % 15 == 0)
            "FizzBuzz"
          else if (i % 5 == 0)
            "Buzz"
          else if (i % 3 == 0)
            "Fizz"
          else
            i.toString()
      ],
      [max.value],
    );

    final onTap = useCallback(
      () async {
        final result = await showDialog<String>(
          context: context,
          builder: (context) => AlertDialog(
            content: TextField(
              onSubmitted: (text) => Navigator.of(context).pop(text),
            ),
          ),
        );
        final newMaxValue = int.tryParse(result ?? "");
        if (newMaxValue == null) return;
        max.value = newMaxValue;
      },
      [max.value],
    );

    return Column(
      children: [
        Row(children: [
          Slider(
            value: max.value.toDouble(),
            onChanged: (value) => max.value = value.toInt(),
            min: 1,
            max: 100,
          ),
          ElevatedButton(
            onPressed: onTap,
            child: Text("show"),
          ),
        ]),
        Expanded(
          child: ListView.builder(
            itemCount: list.length,
            itemBuilder: (context, index) => Text(list[index]),
          ),
        ),
      ],
    );
  }
}

先ほどのonTapを、buildの中で関数自体を毎回定義したりせずに、適切にキャッシュしながら定義できるようになりました。そして、ValueNotifier<int>を引数に渡して…といったこともしなくてよくなりました。

Riverpodと併用する場合であれば、だいたいここまででもかなりのケースに対応できます。useEffectなどを使用する場面も多少あるかもしれませんが、Riverpodの辛い点を吸収するという点においてはかなり書きやすくなります。

カスタムフックを使用して、Side Effectを書きやすくする

Side Effectの話をします。Riverpodの辛い点です。Side Effectsのドキュメントを読むとわかりますが、この「ボタンを押される前、ボタンを押したあと、ローディングや完了の状態管理を行う」ことはそれなりに厄介です。ローディング中はボタンを非活性…といったところまで考え始めると、なかなかつらいです。ボタンの二度押しを避けるために手こずったことがある方も少なくないのではないでしょうか。

上記の例で挙げられているuseFutureを使う例ですら、そもそものFutureやFutureBuilderの取り扱い(FutureBuilderウィジェット)が煩雑であるがゆえにややこしいです。

せっかくRiverpodにはAsyncNotifierProviderがあるのに、これもSide Effectには使いにくいです。(Riverpod 3.0では強化されるそうですが、7月現時点ではまだdev.3にも入っていません。それに3.0が出たからといって既存のコードを対応させることが容易でないケースもあるでしょう。)

しかしながら、flutter_hooksでAsyncValue<T>をうまく組み合わせると、非常に簡潔になります。ボタンの二度押し対策も簡単に対策できます。

class AsyncOperation<T> {
  final AsyncValue<T>? value;
  final Future<void> Function() execute;

  /// onPressed: に渡し、AsyncLoading() のとき非活性に、それ以外のときは活性にするためのユーティリティ
  Future<void> Function()? get executeOrNull =>
      value is AsyncLoading ? null : execute;

  const AsyncOperation({required this.value, required this.execute});
}

AsyncOperation<T> useAsync<T>(Future<T> Function() future) {
  final result = useState<AsyncValue<T>?>(null);

  return AsyncOperation(
    value: result.value,
    execute: () async {
      result.value = const AsyncLoading();

      try {
        final value = await future();
        result.value = AsyncData(value);
      } catch (error, stack) {
        result.value = AsyncError(error, stack);
      }
    },
  );
}

このようなカスタムフックを作成し、ここに任意の非同期処理を渡します。
ここでは、先程のonTapを渡してみましょう。


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

  
  Widget build(BuildContext context) {
    final max = useState(1);
    final list = useMemoized(
      () => [
        for (int i = 1; i < max.value; i++)
          if (i % 15 == 0)
            "FizzBuzz"
          else if (i % 5 == 0)
            "Buzz"
          else if (i % 3 == 0)
            "Fizz"
          else
            i.toString()
      ],
      [max.value],
    );

    final onTap = useAsync(
      () async {
        final result = await showDialog<String>(
          context: context,
          barrierDismissible: true,
          builder: (context) => AlertDialog(
            content: TextField(
              onSubmitted: (text) => Navigator.of(context).pop(text),
            ),
          ),
        );
        final newMaxValue = int.tryParse(result ?? "");
        if (newMaxValue == null) return;
        max.value = newMaxValue;
      },
    );

    return Column(
      children: [
        Row(children: [
          Slider(
            value: max.value.toDouble(),
            onChanged: (value) => max.value = value.toInt(),
            min: 1,
            max: 100,
          ),
          ElevatedButton.icon(
            onPressed: onTap.executeOrNull,
            label: Text("show"),
            icon: onTap.value is AsyncLoading
                ? SizedBox.square(
                    dimension: 14,
                    child: CircularProgressIndicator(),
                  )
                : null,
          ),
        ]),
        Expanded(
          child: ListView.builder(
            itemCount: list.length,
            itemBuilder: (context, index) => Text(list[index]),
          ),
        ),
      ],
    );
  }
}

このようにすれば、非同期処理が実行中のときは非活性でそれ以外のときは活性、といったことが簡単にできるようになります。例外処理で共通のエラーハンドリングを行ったりすることも簡単にできます。

さらに、AsyncValue.when (Riverpod 3.0以降はswitch文)で状態を網羅しながら、それぞれの状態に応じて異なる画面を表示させたりすることも簡単にできるようになります。

おわりに

Riverpodが辛いと長らく思っていたのですが、あらためてRiverpodのドキュメントを読み直し、flutter_hooksを「Riverpodが辛い」という観点から見つめて入門しました。これはなんというか、「知ると戻れない」感じがしますね。

いまは覚えたてのflutter_hooksを使いながら、リファクタリングをがっつり行っている最中です……

https://github.com/shiosyakeyakini-info/miria/pull/604/commits/9ae5699df54cd6ab9f6cc5ea38e7a06705272ac1

Discussion

DiegoDiego

少し気になったので、コメントさせてください!
以下のコードはuseEffectを使った方が使用用途に合ってるかなと思いました!

    useMemoized(() {
      controller.addListener(() {
        ref.read(inputTextStateProvider.notifier).state = controller.text;
      });      

初回build時だけに行いたい場合、
useEffectの第二引数に空配列入れるとそのような動きになります!

以下のように記載がありますが、useMemorizedはこのキャッシュ機能が存在意義だと思います!

useMemoizedのメリットは、やや重たい初期処理の結果をキャッシュして、その値を別のコントローラーなどに渡す初期値としても使うことができるという点もあります。