🪝

【Flutter】カスタムフックで状態とロジックを関数ベースに整理する:ViewModelの代替としての選択肢

に公開

はじめに

Flutterで状態管理といえば、ViewModelやStateNotifierを使うことが一般的です。
しかし、画面やモーダルのように限定的なスコープでは、クラスベースの構造が逆に煩雑になることもあります。

この記事では、flutter_fooksと関数ベースのカスタムフックを使って状態とロジックをWidgetスコープに閉じ込め、
よりシンプルで明快な構造を実現する方法を紹介します。

記事の対象者

  • Riverpod や ViewModel を使った状態管理にある程度慣れてきた方
  • クラスベースの ViewModel 設計に煩わしさを感じている方
  • 画面単位でロジックと状態をスッキリまとめたい方
  • Flutter Hooks による関数ベースの状態管理に興味がある方
  • Flutter における UI 層の構造整理・責務の分離に関心がある方

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.29.0, on macOS 15.3.1 24D70 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.98.2)

使用パッケージ

ViewModel

https://pub.dev/packages/riverpod

https://pub.dev/packages/freezed

カスタムフック

https://pub.dev/packages/flutter_hooks

モチベーション

これまで自分は画面単位の状態管理に対して、RiverpodのStateNotifierやViewModelパターンを用いることが多くありました。
しかし、以下のような課題を感じるようになりました。

  • 状態がViewModelに閉じきれず、Widget側にも分散してしまう(例:TextEditingControllerの扱い)
  • 単一の画面やモーダルのような限定的なスコープにおいても、クラスベースのViewModelを使う必要があることに違和感がある
  • riverpodの Provider は本来グローバルステートの管理に使うべきであり、エフェメラルステートに使うべきではないという設計思想に共感している

そこで、状態とロジックをWidget内に閉じ込める方法として、関数ベースのカスタムフックを導入してみたところ、非常にスッキリとした構成になりました。
この体験をもとに、「ViewModelの代替としてのカスタムフック」 というテーマで本記事を書いています。

ViewModelとは?

ViewModelとはView(UI)の状態と状態の操作を行うロジックを定義する役割を指します。
また、アーキテクチャによってはデータ層やアプリケーション層などのブリッジとなる役割も担います。

言葉の始まりはアーキテクチャのMVVM(Model-View-VewModel)から来ています。

定義の仕方としてはクラスで定義するのが一般的で、riverpodを使用している場合は以下のような形で定義することが一般的です。

// 画面の状態

class ScreenState with _$ScreenState {
  const factory ScreenState({
    ('') String content,
    // ...
  }) = _ScreenState;
}

// 状態を操作する & データ層を呼び出すブリッジ役

class ScreenViewModel extends _$ScreenViewModel {
  
  BlueModalState build() {
    return const ScreenState();
  }
  // 状態の操作
  void setContent(String content) {
    state = state.copyWith(content: content);
  }

  // ダータ層の関数を呼び出す
  Future<void> saveData() async {
    final repository = ref.read(dataRepositoryProvider);
    await repository.save(state.content)
  }
}

riverpodを使ったViewModelのデメリット

ViewModelのように特定の画面やWidgetの状態とロジックを定義している以上、疎結合ではなく、より密結合な状態であるべきです。
しかし、よくも悪く Provider はグローバルな値であるためどこからでも参照できてしまいます。
画面のロジックである以上、汎用的に他の画面で使い回す必要はないと思っています。

また、データ層の呼び出しについては、仮に複数の画面で同じメソッドを呼び出すことがあったとします。
すると当然そのViewModelに関数ができるので、呼び出されている画面が多くなればなるほどボイラープレートは増えていきます。
そして変更があった場合の影響範囲も多くなります。

そこでその課題を解決する手段としてカスタムフックがベストと考えました。

モーダルのロジックを例題として比較してみる

以下の機能を実装したモーダルのロジックをViewModelで作った場合とカスタムフックで作った場合を比較していきます。

  • テキストを入力できる
  • スイッチをオン、オフで切り替えできる
  • 複数のアイテムを選択できる
  • テキストに入力があり、かつ1つ以上アイテムを選択すると閉じるボタンを活性化する
  • 閉じるボタンを押すと入力結果を呼び出しもとに返す
  • 背景色設定 ( backgroundColorProvider )の変更を検知すると全ての編集がリセットされる

サンプルプロジェクト

https://www.youtube.com/watch?v=XIYCzJFwg9Y

ソースコード

https://github.com/HaruhikoMotokawa/custom_hook_sample#

riverpodでViewModelを定義してみる

まずは一般的なViewMoodelを クラスベースであるriverpodの StateNotifierProvider で定義してみます。

lib/presentations/screen/home/components/blue/view_model.dart

class BlueModalState with _$BlueModalState {
  const factory BlueModalState({
    (false) bool isSwitchOn,
    ({}) Set<String> selectedItems,
  }) = _BlueModalState;
}


class BlueModalViewModel extends _$BlueModalViewModel {
  
  BlueModalState build() {
    // 背景色の変更を監視
    final subscription = ref.listen(backgroundColorProvider, (_, __) {
      // 初期値に戻す
      state = const BlueModalState();
    });
    // 自身が破棄された時に、subscriptionをcloseする
    ref.onDispose(subscription.close);
    return const BlueModalState();
  }

  /// isSwitchOnの入力
  void setSwitch() => state = state.copyWith(isSwitchOn: !state.isSwitchOn);

  /// itemを選択する
  void selectItem(String itemId) {
    final isSelected = state.selectedItems.contains(itemId);

    final updateItems = <String>{}..addAll(state.selectedItems);
    if (isSelected) {
      updateItems.remove(itemId);
    } else {
      updateItems.add(itemId);
    }

    state = state.copyWith(selectedItems: updateItems);
  }

  /// doneボタンを活性化させることができるかどうか
  bool isDoneEnabled({required bool textIsNotEmpty}) =>
      textIsNotEmpty && state.selectedItems.isNotEmpty;

  /// modalの結果を取得
  BlueModalResult getResult(String inputText) => (
        inputText: inputText,
        isSwitchOn: state.isSwitchOn,
        selectedItems: state.selectedItems
      );
}
Modal
lib/presentations/screen/home/components/blue/modal.dart
typedef BlueModalResult = ({
  String inputText,
  bool isSwitchOn,
  Set<String> selectedItems,
});

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

  static Future<BlueModalResult?> show(BuildContext context) async {
    return showModalBottomSheet<BlueModalResult?>(
      context: context,
      showDragHandle: true,
      builder: (context) => const BlueModal(),
    );
  }

  static const _itemIds = ['A', 'B', 'C'];

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(blueModalViewModelProvider);
    final viewModel = ref.watch(blueModalViewModelProvider.notifier);

    final textController = useTextEditingController(text: '');
    final textIsNotEmpty = useListenableSelector(
      textController,
      () => textController.text.isNotEmpty,
    );

    ref.listen(backgroundColorProvider, (_, __) {
      textController.clear();
    });

    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        spacing: 14,
        children: [
          const Text('Blue Modal'),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 10),
            child: TextField(
              controller: textController,
              decoration: const InputDecoration(
                labelText: 'Enter text',
                border: OutlineInputBorder(),
              ),
            ),
          ),
          SwitchListTile(
            value: state.isSwitchOn,
            title: const Text('Switch'),
            onChanged: (_) => viewModel.setSwitch(),
          ),
          Column(
            children: _itemIds.map((id) {
              return CheckboxListTile(
                value: state.selectedItems.contains(id),
                title: Text('Select Item $id'),
                onChanged: (_) => viewModel.selectItem(id),
              );
            }).toList(),
          ),
          SizedBox(
            width: 300,
            height: 50,
            child: ElevatedButton(
              onPressed: viewModel.isDoneEnabled(textIsNotEmpty: textIsNotEmpty)
                  ? () {
                      final result = viewModel.getResult(textController.text);
                      Navigator.pop(context, result);
                    }
                  : null,
              child: const Text('Done'),
            ),
          ),
        ],
      ),
    );
  }
}

問題点

問題 1
最初の方でもあげたrefを使ってどこからでもVideModelを呼び出せてしまう問題がります。

問題 2
状態がViewModelだけで完結しない点があります。
本来であればテキストの入力の値である TextEditingController が管理する値も inputText のような変数でstateに定義したいところです。
しかし、flutterが提供するXxxController系はUIへの依存が強いため、ViewModel側でそのまま引数で受け取ったり、直接stateに受け取るのが難しいです。
よってこれだけはWidget側で管理しています。

問題 3
閉じるボタンを活性化するかどうかを判定するにはテキストが入力されているかどうかを知る必要があるため、
引数で入力値を受け取り、stateの状態と合わせて判定する、というまどろっこしい事態が発生します。

/// doneボタンを活性化させることができるかどうか
bool isDoneEnabled({required bool textIsNotEmpty}) =>
    textIsNotEmpty && state.selectedItems.isNotEmpty;
final textController = useTextEditingController(text: '');
final textIsNotEmpty = useListenableSelector(
  textController,
  () => textController.text.isNotEmpty,
);

// ...

child: ElevatedButton(
  onPressed: viewModel.isDoneEnabled(textIsNotEmpty: textIsNotEmpty)
    ? () {
        final result = viewModel.getResult(textController.text);
        Navigator.pop(context, result);
      }
    : null,
  ),

問題 4
backgroundColorProvider の変更を検知した場合にリセットしなければいけないものが stat だけでなく、TextEditingController のテキストもなので、Widget側でも変更を監視しなければいけません。

  
  Widget build(BuildContext context, WidgetRef ref) {
    // ...

    final textController = useTextEditingController(text: '');

    ref.listen(backgroundColorProvider, (_, __) {
      textController.clear();
    });

問題 5
Provider 内で別の Provider を購読するとキャンセル処理を書かなければいけないので、考慮事項とコード量が増えます。


class BlueModalViewModel extends _$BlueModalViewModel {
  
  BlueModalState build() {
    // 背景色の変更を監視
    final subscription = ref.listen(backgroundColorProvider, (_, __) {
      // 初期値に戻す
      state = const BlueModalState();
    });
    // 自身が破棄された時に、subscriptionをcloseする
    ref.onDispose(subscription.close);
    return const BlueModalState();
  }
  // ...
}

カスタムフックの作り方

まずはカスタムフックの概要と簡易版の作り方を解説していきます。

カスタムフックとは

カスタムフックは元々 JavaScriptのフレームワークであるReactにある技術です。
複数のhookをまとめ、状態と状態の操作を一括で定義するものです。
クラスベースではなく、関数ベースなのが特徴です。

カスタムフックを使わないで、普通に定義した場合


Widget build(BuildContext context) {
  final isAppleSwitch = useState(false);

  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      spacing: 40,
      children: [
        const Text('Red Modal'),
        SwitchListTile(
          value: isAppleSwitch.value,
          onChanged: (value) => isAppleSwitch.value = value,
          title: const Text('Apple Switch'),
        ),

      // ...

      ],
    ),
  );
}

スイッチの状態をflutter_hooksの useState で定義しています。
状態の変更はそのまま SwitchListTileonChanged に記載しています。
状態と状態の操作は別々に定義されています。

カスタムフックで定義した場合

typedef BananaSwitchController = ({
  bool isBananaSwitch,
  void Function() setBananaSwitch,
});

BananaSwitchController useBananaSwitchController() {
  final isBananaSwitch = useState(false);

  void setBananaSwitch() => isBananaSwitch.value = !isBananaSwitch.value;

  return (
    isBananaSwitch: isBananaSwitch.value,
    setBananaSwitch: setBananaSwitch,
  );
}

BananaSwitchController

カスタムフック内で定義した状態や状態を操作する関数は最終的にタプル型にして返します。
型エイリアスにするとより意図が明確になるのでおすすめです。

型エイリアスとタプル型を併用した活方法は以下でも解説しています。

https://zenn.dev/harx/articles/d766f243639258

型エイリアスを使わない場合はこんな感じ
(bool, void Function()) useBananaSwitchControllerExample() {
  final isBananaSwitch = useState(false);

  void setBananaSwitch() => isBananaSwitch.value = !isBananaSwitch.value;

  return (
    isBananaSwitch.value,
    setBananaSwitch,
  );
}

useBananaSwitchController()

hookの命名は慣習的に useXxx とすることになっています。
この useBananaSwitchController() の中で通常と同じように useState を定義したり、
状態の操作を行う関数を定義したりします。
そうして最終的には定義した内容をタプルにして返します。

useBananaSwitchController() を呼び出す


Widget build(BuildContext context) {

  final bananaSwitchController = useBananaSwitchController();
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      spacing: 40,
      children: [

        // ...

        SwitchListTile(
          value: bananaSwitchController.isBananaSwitch,
          onChanged: (_) => bananaSwitchController.setBananaSwitch(),
          title: const Text('Banana Switch'),
        ),

        // ...

      ],
    ),
  );
}

変数 bananaSwitchController で状態や関数を受け取っています。
あとは 変数.xxx で状態や関数にアクセスできます。
この例だとわざわざカスタムフックにするメリットはありません。
しかし、複数の状態がある場合はこれを活用していくと以下のメリットが生まれます。

  • 状態をまとめて管理できる
  • 状態の操作を各Widgetの処理内に書かずに済むのでWidgetごとのの見通しをよくできる
  • build関数内でhookを列挙しなくて済むので、見通しが良くなる

モーダルの状態とロジックをカスタムフックで定義してみる

lib/presentations/screen/home/components/orange/_use_modal_controller.dart
part of 'modal.dart';

typedef _ModalController = ({
  bool isSwitchOn,
  Set<String> selectedItems,
  bool isDoneEnabled,
  TextEditingController textController,
  VoidCallback setSwitch,
  void Function(String itemId) selectItem,
  OrangeModalResult Function() getResult,
});

_ModalController _useModalController(WidgetRef ref) {
  // INFO: modalの状態を定義 ->
  final textController = useTextEditingController(text: '');
  final isSwitchOn = useState(false);
  final selectedItems = useState(<String>{});
  final isDoneEnabled = useState(false);
  // <- modalの状態を定義

  // INFO: 値の変更を監視して、副作用の処理を定義 ->
  // 背景色の変更を監視
  ref.listen(backgroundColorProvider, (_, __) {
    // 初期値に戻す
    textController.clear();
    isSwitchOn.value = false;
    selectedItems.value = {};
  });

  // textControllerの入力状態を監視
  final textIsNotEmpty = useListenableSelector(
    textController,
    () => textController.text.isNotEmpty,
  );

  // [textIsNotEmpty], [selectedItems]の値を監視
  // 上記の2つが全て編集された場合はisDoneEnabledの値をtrueにする
  useEffect(
    () {
      isDoneEnabled.value = textIsNotEmpty && selectedItems.value.isNotEmpty;
      return null;
    },
    [textIsNotEmpty, selectedItems.value],
  );
  // <- 値の変更を監視して、副作用の処理を定義

  // INFO: 状態の変更を行う関数を定義 ->
  /// isSwitchOnの入力
  void setSwitch() => isSwitchOn.value = !isSwitchOn.value;

  /// itemを選択する
  void selectItem(String itemId) {
    final isSelected = selectedItems.value.contains(itemId);
    final updateItems = <String>{}..addAll(selectedItems.value);
    if (isSelected) {
      updateItems.remove(itemId);
    } else {
      updateItems.add(itemId);
    }
    selectedItems.value = updateItems;
  }

  /// modalの結果を取得
  OrangeModalResult getResult() => (
        inputText: textController.text,
        isSwitchOn: isSwitchOn.value,
        selectedItems: selectedItems.value,
      );
  // <- 状態の変更を行う関数を定義

  // INFO: modalの状態、関数を返す
  return (
    isSwitchOn: isSwitchOn.value,
    selectedItems: selectedItems.value,
    isDoneEnabled: isDoneEnabled.value,
    textController: textController,
    setSwitch: setSwitch,
    selectItem: selectItem,
    getResult: getResult,
  );
}

Modal
lib/presentations/screen/home/components/orange/modal.dart
part '_use_modal_controller.dart';

typedef OrangeModalResult = ({
  String inputText,
  bool isSwitchOn,
  Set<String> selectedItems,
});

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

  static Future<OrangeModalResult?> show(BuildContext context) async {
    return showModalBottomSheet<OrangeModalResult?>(
      context: context,
      showDragHandle: true,
      builder: (context) => const OrangeModal(),
    );
  }

  static const _itemIds = ['A', 'B', 'C'];

  
  Widget build(BuildContext context, WidgetRef ref) {
    final modalController = _useModalController(ref);
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        spacing: 14,
        children: [
          const Text('Orange Modal'),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 10),
            child: TextField(
              controller: modalController.textController,
              decoration: const InputDecoration(
                labelText: 'Enter text',
                border: OutlineInputBorder(),
              ),
            ),
          ),
          SwitchListTile(
            value: modalController.isSwitchOn,
            title: const Text('Switch'),
            onChanged: (_) => modalController.setSwitch(),
          ),
          Column(
            children: _itemIds.map((id) {
              return CheckboxListTile(
                value: modalController.selectedItems.contains(id),
                title: Text('Select Item $id'),
                onChanged: (_) => modalController.selectItem(id),
              );
            }).toList(),
          ),
          SizedBox(
            width: 300,
            height: 50,
            child: ElevatedButton(
              onPressed: modalController.isDoneEnabled
                  ? () {
                      final result = modalController.getResult();
                      Navigator.pop(context, result);
                    }
                  : null,
              child: const Text('Done'),
            ),
          ),
        ],
      ),
    );
  }
}

改善点

改善 1
カスタムフックは関数ベースであり、尚且つプライベートメソッドにすることもできます。
つまり、同じファイル内だけで呼び出すようにしてしまえば、他の画面やWidgetで使えなくすることもできます。

また状態の名称を今回は _ModalController としてみました。ここではstateだけではなく、useTextEditingController なども返すことを考慮して state や ViewModel という命名は避けてみました。

なお、この型エイリアスもプライベートにすることができるので、他ファイルとの命名の衝突を気にせずにつけることができます。

ちなみに本来であればプライベートメソッドは同一ファイルでしか呼び出せませんが、今回は part を使ってファイル分割しています。個人的には分けるのが好みですが、ここはお好みで大丈夫です。

partを使ってファイル分割する方法は以下の記事で解説しています。

https://zenn.dev/harx/articles/09d569d011bb4f

改善 2

テキストの入力の値も useTextEditingController から直接扱うことができるようになりました。
そのことでViewModelに比べて getResult() は引数を必要とせず、わかりやすいコードになったと思います。

/// modalの結果を取得
OrangeModalResult getResult() => (
      inputText: textController.text,
      isSwitchOn: isSwitchOn.value,
      selectedItems: selectedItems.value,
    );

改善 3
閉じるボタンを活性化するかどうかを判定するロジックを一元管理できるようになりました。

final textController = useTextEditingController(text: '');
// ...
final isDoneEnabled = useState(false);

// ...

// textControllerの入力状態を監視
final textIsNotEmpty = useListenableSelector(
  textController,
  () => textController.text.isNotEmpty,
);

// [textIsNotEmpty], [selectedItems]の値を監視
// 上記の2つが全て編集された場合はisDoneEnabledの値をtrueにする
useEffect(
  () {
    isDoneEnabled.value = textIsNotEmpty && selectedItems.value.isNotEmpty;
    return null;
  },
  [textIsNotEmpty, selectedItems.value],
);

改善 4
ViewModelの問題4と5を解決できるようになりました。
backgroundColorProvider を監視する処理はこのカスタムフック内だけで良くなりました。
また、 Provider でなくなったことで、購読のキャンセル処理も必要なくなりました。

// 背景色の変更を監視
ref.listen(backgroundColorProvider, (_, __) {
  // 初期値に戻す
  textController.clear();
  isSwitchOn.value = false;
  selectedItems.value = {};
});

カスタムフックの問題点

ここまでカスタムフックのメリットばかりを述べてきましたが、デメリットも少なからずあると思っています。

問題 1
関数ベースという特殊な書き方を理解する必要があります。
Flutter/Dartを扱うエンジニアはオブジェクト思考で考えていると思われます。
ようは何かを司るものは総じてクラスであるという認識が強いと思っています。

なので、このUI要素の状態と要素を司るのが関数である、という新たな概念を理解する必要があります。

問題 2
関数なので、書き方に気をつけないと中身が煩雑になってしまいます。
どこに状態の変数があって、副作用があって、関数があるのか。

個人的には以下の順番が読みやすくて良いと思っていますが、これはチームであれば合意を取っておく必要があります。

_XxxController useXxxController(){
  // 1. 状態の変数
  // useState
  // useAnimationControllerなど

  // 2. 副作用
  // ref.listen
  // useEffectなど

  // 3. 関数
  // void xxx(){
  // ...
  // }

  // 4. 最後にリターンは固定
  return (
    // ...
  );
}

終わりに

Flutterで状態管理をする際、ViewModel(特にriverpodのStateNotifierなど)を使うことが一般的ですが、
その分、状態やロジックが肥大化しやすく、シンプルな画面やモーダルにおいては過剰な責務を抱えてしまうこともあります。

今回紹介したように、関数ベースのカスタムフックを使うことで、
「状態をWidgetのスコープ内に閉じ込める」シンプルで自然な設計が実現できると感じました。

特に、UIの構造と状態の関連性が強い場面では、クラスに頼らず関数でまとめる方が、
「どこで何をしているか」が明確になり、コードの見通しも良くなります。

もちろん、どんな場面でもViewModelを使うべきではない、という話ではありません。
グローバルに共有される状態やアプリ全体に影響を与えるロジックは、依然としてViewModelやProviderを使うべきです。

ただ、状態の責務とスコープを正しく分離することで、
「どこに状態を置くべきか?」という設計判断がよりクリアになると思っています。

この取り組みが、Flutterアプリ開発における状態管理の設計を見直すきっかけになれば幸いです。

参考記事

https://qiita.com/DiegoHonda/items/5c81bdfd347697c56331

Discussion