🪝

【Flutter】flutter_hooks のメソッドをまとめる②

2024/12/11に公開

初めに

今回は、前回に引き続き、 flutter_hooks に用意されているメソッドをいくつか抽出して、その用途をまとめて見たいと思います。

前回の記事

https://zenn.dev/koichi_51/articles/b2be795ffc7900

記事の対象者

  • Flutter 学習者
  • flutter_hooks について知りたい方

9. useFocusNode, useFocusScopeNode

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFocusNode.html

Creates an automatically disposed FocusNode.

(日本語訳)
自動的に破棄される FocusNode を作成

使用用途

  1. フォーム入力のフォーカス管理
    複数のテキストフィールド間でのフォーカスの移動が実装可能(例:次のフィールドに自動移動)
  2. フォーカスの状態に応じたUIの変更
    フォーカスが当たったときにボーダーの色を変更するなど、UIの視覚的フィードバックを提供
  3. フォーカスのプログラム的制御
    特定の条件下でウィジェットにフォーカスを当てる実装が可能(例:エラーメッセージ表示時に該当フィールドにフォーカス)

使用方法

以下では、テキストフィールドを入力して Enter を押すと次のフィールドにフォーカスが移るようになっています。また、ボタンで外部からフォーカスを操作できるようになっています。

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

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

  
  Widget build(BuildContext context) {
    // 各テキストフィールド用の FocusNode を作成
    final focusNode1 = useFocusNode();
    final focusNode2 = useFocusNode();
    final focusNode3 = useFocusNode();

    // テキストフィールドの入力内容を管理する状態
    final text1 = useState<String>('');
    final text2 = useState<String>('');
    final text3 = useState<String>('');

    useEffect(() {
      // クリーンアップ
      return () {
        focusNode1.dispose();
        focusNode2.dispose();
        focusNode3.dispose();
      };
    }, [
      focusNode1,
      focusNode2,
      focusNode3,
    ]);

    return Scaffold(
      appBar: AppBar(
        title: const Text('useFocusNode サンプル'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              focusNode: focusNode1,
              decoration: const InputDecoration(
                labelText: '名前',
                border: OutlineInputBorder(),
              ),
              textInputAction: TextInputAction.next,
              onChanged: (value) {
                text1.value = value;
              },
              onSubmitted: (_) {
                // Field2 にフォーカスを移動
                FocusScope.of(context).requestFocus(focusNode2);
              },
            ),
            const SizedBox(height: 20),
            TextField(
              focusNode: focusNode2,
              decoration: const InputDecoration(
                labelText: 'メールアドレス',
                border: OutlineInputBorder(),
              ),
              textInputAction: TextInputAction.next,
              onChanged: (value) {
                text2.value = value;
              },
              onSubmitted: (_) {
                // Field3 にフォーカスを移動
                FocusScope.of(context).requestFocus(focusNode3);
              },
            ),
            const SizedBox(height: 20),
            TextField(
              focusNode: focusNode3,
              decoration: const InputDecoration(
                labelText: 'パスワード',
                border: OutlineInputBorder(),
              ),
              obscureText: true,
              textInputAction: TextInputAction.done,
              onChanged: (value) {
                text3.value = value;
              },
              onSubmitted: (_) {
                // キーボードを閉じる
                focusNode3.unfocus();
                debugPrint('名前: ${text1.value}');
                debugPrint('メールアドレス: ${text2.value}');
                debugPrint('パスワード: ${text3.value}');
              },
            ),
            const SizedBox(height: 30),
            Column(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                ElevatedButton(
                  onPressed: () {
                    FocusScope.of(context).requestFocus(focusNode1);
                  },
                  child: const Text('名前にフォーカス'),
                ),
                const SizedBox(height: 10),
                ElevatedButton(
                  onPressed: () {
                    FocusScope.of(context).requestFocus(focusNode2);
                  },
                  child: const Text('メールアドレスにフォーカス'),
                ),
                const SizedBox(height: 10),
                ElevatedButton(
                  onPressed: () {
                    FocusScope.of(context).requestFocus(focusNode3);
                  },
                  child: const Text('パスワードにフォーカス'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

実行結果
https://dartpad.dev/?null_safety=true&id=814c6ef68b66a6cef387c92f19afe7a6

少しコードを見ていきます。

以下では useFocusNodeFocusNode を作成しています。

final focusNode1 = useFocusNode();
final focusNode2 = useFocusNode();
final focusNode3 = useFocusNode();

以下では useState でテキストの内容を保持しています。

final text1 = useState<String>('');
final text2 = useState<String>('');
final text3 = useState<String>('');

以下では、テキストフィールドの focusNodeuseFocusNode で生成した FocusNode を指定しています。また、 onSubmittedrequestFocus を指定することでテキストフィールドで Enter が押された際に次のフィールドにフォーカスが当たるようにしています。

TextField(
  focusNode: focusNode1,
  decoration: const InputDecoration(
    labelText: '名前',
    border: OutlineInputBorder(),
  ),
  textInputAction: TextInputAction.next,
  onChanged: (value) {
    text1.value = value;
  },
  onSubmitted: (_) {
    // Field2 にフォーカスを移動
    FocusScope.of(context).requestFocus(focusNode2);
  },
),

10. useFuture

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useFuture.html

Subscribes to a Future and returns its current state as an AsyncSnapshot.
preserveState determines if the current value should be preserved when changing the Future instance.
The Future needs to be created outside of useFuture. If the Future is created inside useFuture, then, every time the build method gets called, the Future will be called again. One way to create the Future outside of useFuture is by using useMemoized.

(日本語訳)
Futureに購読し、その現在の状態をAsyncSnapshotとして返します。
preserveStateは、Futureインスタンスを変更した際に現在の値を保持するかどうかを決定します。
FutureはuseFutureの外側で作成する必要があります。FutureがuseFutureの内側で作成されると、ビルドメソッドが呼び出されるたびにFutureが再度呼び出されてしまいます。FutureをuseFutureの外側で作成する一つの方法として、useMemoizedを使用することが挙げられます。

使用用途

  1. データのフェッチ
    APIからデータを取得し、取得完了後にUIを更新
  2. 非同期計算
    時間のかかる計算処理をバックグラウンドで実行し、結果を表示できる
  3. データベース操作
    ローカルデータベースからデータを読み込む、またはデータを保存する際に使用
  4. ファイル操作
    ファイルの読み書きなどのI/O操作を非同期で実行可能

使用方法

以下のコードでは JSON Placeholder API でデータを取得して表示する際に useFuture を用いて実装しています。

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:http/http.dart' as http;

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

  /// データをフェッチする非同期関数
  Future<List<dynamic>> fetchData() async {
    final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
    final response = await http.get(url);
    if (response.statusCode == 200) {
      return json.decode(response.body);
    } else {
      throw Exception('データの取得に失敗しました');
    }
  }

  
  Widget build(BuildContext context) {
    // useFuture を使用して非同期データを取得
    final future = useMemoized(() => fetchData());
    final snapshot = useFuture(future, initialData: null);

    return Scaffold(
      appBar: AppBar(
        title: const Text('useFuture サンプル'),
      ),
      body: Center(
        child: () {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const CircularProgressIndicator();
          } else if (snapshot.hasError) {
            return Text('エラー: ${snapshot.error}');
          } else if (snapshot.hasData) {
            final posts = snapshot.data!;
            return ListView.builder(
              itemCount: posts.length,
              itemBuilder: (context, index) {
                final post = posts[index];
                return ListTile(
                  title: Text(post['title']),
                  subtitle: Text(post['body']),
                );
              },
            );
          } else {
            return const Text('データがありません');
          }
        }(),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=13f6b4826c7de5d503af14c1a7f051f9

コードを見ていきます。

以下では fetchData メソッドで JSON Placeholder API のデータを非同期で取得しています。

Future<List<dynamic>> fetchData() async {
  final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  final response = await http.get(url);
  if (response.statusCode == 200) {
    return json.decode(response.body);
  } else {
    throw Exception('データの取得に失敗しました');
  }
}

以下では future として、 useMemoized を用いて fetchData をメモ化しています。
そして、 useFuturefuture を渡しています。初期のデータは必要ないため initialData は null にしています。

final future = useMemoized(() => fetchData());
final snapshot = useFuture(future, initialData: null);

以下では useFuture で定義した snapshot の状態をもとに表示するビューを切り替えています。

if (snapshot.connectionState == ConnectionState.waiting) {
  return const CircularProgressIndicator();
} else if (snapshot.hasError) {
  return Text('エラー: ${snapshot.error}');
} else if (snapshot.hasData) {
  final posts = snapshot.data!;
  return ListView.builder(
    itemCount: posts.length,
    itemBuilder: (context, index) {
      final post = posts[index];
      return ListTile(
        title: Text(post['title']),
        subtitle: Text(post['body']),
      );
    },
  );
} else {
  return const Text('データがありません');
}

11. useListenable, useListenableSelector

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenable.html

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenableSelector.html

Subscribes to a Listenable and marks the widget as needing build whenever the listener is called.

(日本語訳)
Listenableに購読し、リスナーが呼び出されるたびにウィジェットを再ビルドが必要であるとマークします。

使用用途

  1. テキスト入力のリアルタイム監視
    TextEditingController を使用してテキストフィールドの変更を監視し、リアルタイムで反映
  2. アニメーションの制御
    AnimationController を利用してアニメーションの進行状態を監視し、アニメーションに応じてUIを更新
  3. スクロール位置の追跡
    ScrollController を使用してスクロール位置の変化を監視し、スクロールに応じたUI変更

使用方法

以下では useListenableSelectorTextEditingController を監視して、入力されている内容に応じてエラーを表示させています。

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

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

  /// メールアドレスのバリデーション関数
  bool isValidEmail(String email) {
    final emailRegex = RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@"
        r"[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$");
    return emailRegex.hasMatch(email);
  }

  
  Widget build(BuildContext context) {
    final textController = useTextEditingController();
    final isValid = useListenableSelector(
      textController,
      () => isValidEmail(textController.text),
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('useListenableSelector サンプル'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: textController,
              decoration: InputDecoration(
                labelText: 'メールアドレス',
                labelStyle: TextStyle(
                  color: isValid
                      ? Theme.of(context).colorScheme.primary
                      : Colors.red,
                ),
                border: const OutlineInputBorder(),
                errorText: isValid ? null : '有効なメールアドレスを入力してください',
                focusedBorder: OutlineInputBorder(
                  borderSide: BorderSide(
                    color: isValid
                        ? Theme.of(context).colorScheme.primary
                        : Colors.red,
                    width: 2.0,
                  ),
                ),
                enabledBorder: OutlineInputBorder(
                  borderSide: BorderSide(
                    color: isValid ? Colors.grey : Colors.red,
                    width: 1.0,
                  ),
                ),
              ),
              keyboardType: TextInputType.emailAddress,
              textInputAction: TextInputAction.done,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          textController.clear();
        },
        tooltip: '入力をクリア',
        child: const Icon(Icons.clear),
      ),
    );
  }
}

https://dartpad.dev/?null_safety=true&id=6d0886aa8e6d2ce1dfa8ad692933d53e

コードを見ていきます。

以下では、テキストの内容を保持する textController を定義して、それを useListenableSelector に渡しています。これで textController の動きを監視することができるようになります。

final textController = useTextEditingController();
final isValid = useListenableSelector(
  textController,
  () => isValidEmail(textController.text),
);

12. useMemoized

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMemoized.html

Caches the instance of a complex object.
useMemoized will immediately call valueBuilder on first call and store its result. Later, when the HookWidget rebuilds, the call to useMemoized will return the previously created instance without calling valueBuilder.
A subsequent call of useMemoized with different keys will re-invoke the function to create a new instance.

(日本語訳)
複雑なオブジェクトのインスタンスをキャッシュします。
useMemoized は初回の呼び出し時に即座に valueBuilder を実行し、その結果を保存します。その後、 HookWidget が再ビルドされた場合でも、 useMemoized の呼び出しは以前に作成されたインスタンスを返し、 valueBuilder は再び呼び出されません。
ただし、異なるキーを使用して useMemoized を再び呼び出すと、新しいインスタンスを作成するために関数が再実行されます。

使用用途

  1. 重い計算の結果のメモ化
    ウィジェットの再ビルド時に重い計算処理を繰り返さないため、一度計算した結果をメモ化して再利用
  2. 非同期処理の一度だけの実行
    APIコールやデータベースからのデータ取得などの非同期処理を、一度だけ実行し、その結果を再利用
  3. オブジェクトの一貫性の保持
    再ビルド時に新しいインスタンスが作成されないように、重要なオブジェクト(例えば、ScrollController や AnimationController)をメモ化

使用方法

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

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

  int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
  }

  
  Widget build(BuildContext context) {
    // フィボナッチ数の計算をメモ化
    final fibResult = useMemoized(() => fibonacci(40), []);
    final result = useState<int>(fibResult);

    return Scaffold(
      appBar: AppBar(
        title: const Text('useMemoized サンプル'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('フィボナッチ(40) の結果: ${result.value}'),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                result.value = fibResult;
              },
              child: const Text('結果を更新'),
            ),
          ],
        ),
      ),
    );
  }
}

https://dartpad.dev/?null_safety=true&id=b909ff027cb364b7277158ca6f0138e0

useMemoized を使用することで、フィボナッチ計算は一度だけ実行され、その結果がメモ化されます。Widget が再ビルドされても、計算は再実行されず、メモ化された値が利用されるため、パフォーマンスが向上します。
今回はフィボナッチ計算にしていますが、他にもデータの取得処理など複数応用できるかと思います。

13. useOnAppLifecycleStateChange

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnAppLifecycleStateChange.html

Listens to the AppLifecycleState.

(日本語訳)
AppLifecycleState をリッスンして監視

使用用途

  1. データの自動保存
    アプリがバックグラウンドに移行する際に、ユーザーのデータやセッション情報を自動的に保存
  2. リソースの解放と再確保
    アプリが非アクティブになる際に、不要なリソース(例えば、メモリやバッテリー消費の高いタスク)を解放し、再アクティブ化時に再確保
  3. プッシュ通知の管理
    アプリがアクティブかどうかに応じて、プッシュ通知の受信や表示方法を調整

使用方法

以下のように useOnAppLifecycleStateChange でアプリのライフサイクルを監視して、状態が変化した時にその状態を出力するようにしています。

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

class UseOnAppLifecycleStateChangeSample extends HookWidget {
  const UseOnAppLifecycleStateChangeSample({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    final statusText = useState('不明な状態');

    useOnAppLifecycleStateChange((beforeState, currState) {
      switch (currState) {
        case AppLifecycleState.resumed:
          statusText.value = 'アプリはフォアグラウンドにあります';
          debugPrint('アプリはフォアグラウンドにあります');
          break;
        case AppLifecycleState.inactive:
          statusText.value = 'アプリは非アクティブ状態です';
          debugPrint('アプリは非アクティブ状態です');
          break;
        case AppLifecycleState.paused:
          statusText.value = 'アプリは一時停止しました';
          debugPrint('アプリは一時停止しました');
          break;
        case AppLifecycleState.detached:
          statusText.value = 'アプリは終了しました';
          debugPrint('アプリは終了しました');
          break;
        case AppLifecycleState.hidden:
          statusText.value = 'アプリは非表示になりました';
          debugPrint('アプリは非表示になりました');
          break;
        default:
          statusText.value = '不明な状態';
          debugPrint('不明な状態');
      }
    });

    return Scaffold(
      appBar: AppBar(
        title: const Text('useOnAppLifecycleStateChange サンプル'),
      ),
      body: Center(
        child: Text(statusText.value),
      ),
    );
  }
}

https://dartpad.dev/?null_safety=true&id=cea23f9ebbb4c9d16f99a8af4050d9ac

14. usePageController

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/usePageController.html

Creates a PageController that will be disposed automatically.

(日本語訳)
自動的に破棄される PageController を作成します

使用用途

  1. ページ間のプログラム的な移動
    ボタンや他のUI要素から特定のページに移動する際に使用(初めのページに戻るなど)
  2. ページインジケーターとの同期
    ページインジケーターと PageView を連動させて現在のページの位置を表示

使用方法

以下のコードのように usePageControllerPageController を作成し、それに対してリスナーを追加することで PageView のページ番号を管理することができます。

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

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

  
  Widget build(BuildContext context) {
    // usePageController を使用して PageController を作成
    final pageController = usePageController(initialPage: 0);
    final currentPage = useState<int>(0);

    useEffect(() {
      void listener() {
        currentPage.value = pageController.page?.round() ?? 0;
      }
      pageController.addListener(listener);

      return () => pageController.removeListener(listener);
    }, [pageController]);

    return Scaffold(
      appBar: AppBar(
        title: const Text('usePageController サンプル'),
      ),
      body: Column(
        children: [
          Expanded(
            child: PageView(
              controller: pageController,
              children: const [
                Center(
                  child: Text(
                    'ページ 1',
                    style: TextStyle(fontSize: 24),
                  ),
                ),
                Center(
                  child: Text(
                    'ページ 2',
                    style: TextStyle(fontSize: 24),
                  ),
                ),
                Center(
                  child: Text(
                    'ページ 3',
                    style: TextStyle(fontSize: 24),
                  ),
                ),
              ],
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: List.generate(3, (index) {
              return Container(
                margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 16),
                width: currentPage.value == index ? 12 : 8,
                height: currentPage.value == index ? 12 : 8,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color:
                      currentPage.value == index ? Colors.black : Colors.grey,
                ),
              );
            }),
          ),
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                TextButton(
                  onPressed: () {
                    if (currentPage.value > 0) {
                      pageController.animateToPage(
                        currentPage.value - 1,
                        duration: const Duration(milliseconds: 300),
                        curve: Curves.easeInOut,
                      );
                    }
                  },
                  child: const Text('前へ'),
                ),
                TextButton(
                  onPressed: () {
                    if (currentPage.value < 2) {
                      pageController.animateToPage(
                        currentPage.value + 1,
                        duration: const Duration(milliseconds: 300),
                        curve: Curves.easeInOut,
                      );
                    }
                  },
                  child: const Text('次へ'),
                ),
              ],
            ),
          ),
          const SizedBox(height: 20),
        ],
      ),
    );
  }
}

https://dartpad.dev/?null_safety=true&id=a778a3306e4575f436ebe7f5ce2c6731

15. useReducer

概要

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useReducer.html

An alternative to useState for more complex states.
useReducer manages a read only instance of state that can be updated by dispatching actions which are interpreted by a Reducer.
reducer is immediately called on first build with initialAction and initialState as parameter.
It is possible to change the reducer by calling useReducer with a new Reducer.

(日本語訳)
より複雑な状態を扱うための useState の代替手段です。
useReducerは、 Reducer によって解釈されるアクションをディスパッチすることで更新可能な、読み取り専用の状態インスタンスを管理します。
初回のビルド時に、Reducer は initialAction と initialState をパラメーターとして即座に呼び出されます。
新しいリデューサーを使用して useReducer を呼び出すことで、リデューサーを変更することが可能です。

使用用途

  1. 複数の関連する状態を管理
    useReducer を使用して一元管理することで、状態の一貫性を保つ
  2. 複雑な状態遷移ロジック
    状態更新が複数のステップや条件に依存する場合、Reducer 関数内でロジックを整理することで、コードの可読性と保守性が向上する
  3. フォーム管理
    複数の入力フィールドやバリデーションロジックを持つフォームの状態管理

使用方法

以下のようなコードで、Flutter において Redux アーキテクチャを採用する場合に使用されることが多いかと思います。

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


class CounterState {
  const CounterState({this.count = 0});
  final int count;

  CounterState copyWith({int? count}) {
    return CounterState(count: count ?? this.count);
  }
}

sealed class CounterAction {}

class CounterIncrementAction implements CounterAction {
  const CounterIncrementAction();
}

class CounterDecrementAction implements CounterAction {
  const CounterDecrementAction();
}

class CounterResetAction implements CounterAction {
  const CounterResetAction();
}

typedef CounterStore = Store<CounterState, CounterAction>;
typedef CounterReducer = Reducer<CounterState, CounterAction>;
CounterReducer get reducer => (state, action) {
      return switch (action) {
        CounterIncrementAction() => state.copyWith(count: state.count + 1),
        CounterDecrementAction() => state.copyWith(count: state.count - 1),
        CounterResetAction() => state.copyWith(count: 0),
      };
    };

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

  
  Widget build(BuildContext context) {
    final CounterStore store = useReducer(
      reducer,
      initialState: const CounterState(),
      initialAction: const CounterResetAction(),
    );
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              '${store.state.count}',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                IconButton(
                  color: Colors.red,
                  onPressed: () =>
                      store.dispatch(const CounterIncrementAction()),
                  icon: const Icon(Icons.add),
                ),
                IconButton(
                  color: Colors.blue,
                  onPressed: () =>
                      store.dispatch(const CounterDecrementAction()),
                  icon: const Icon(Icons.remove),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

実行結果

https://dartpad.dev/?null_safety=true&id=a3d84c27cd3f26eee8a88ee96d92849e

まとめ

最後まで読んでいただいてありがとうございました。

今回も flutter_hooks に用意されているメソッドについてみてきました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/flutter_hooks-library.html

Discussion