🥉

入力フォームの状態管理について考えてみた

2024/01/04に公開

対象者

  • 入力フォームの状態管理に興味がある人
  • StatefulWidget, flutter_hooks, riverpodを使っている

やること/やらないこと

やること:

  • 3パターンでFormの状態管理をしてみる
  • riverpodだけ、全部プロバイダーではない。私は昔全部プロバイバーにしてた!

やらないこと:

  • 入力フォームの状態管理は私の考えでやってるので他人と比較しない
  • 何がベストかなと追求しない。これって宗教戦争な気がする

プロジェクトの説明

昔、StatefulWidgetを使うと嫌がられたことがある。それ以来hooks_riverpodflutter_hooksを使い続けてきた...
でもある日hooks_riverpodflutter_hooks使わないように言われた。
それ以来使うのは避けて、ConsumerWidgetConsumerStatefulWidgetを使っていた。どう使い分けていたかというと、入力フォームのようなそのページでしか状態を扱わない機能が多いページは、ConsumerStatefulWidgetを使い、APIやFireSotreからデータをfetchして表示するだけのページは、ConsumerWidgetでやっていました。

とはいえ、僕はReactチックに使えて案件でも多く使われているhooks_riverpodflutter_hooksを使うのを押し通してますね。
useStateuseMemoizeduseTextEditingControllerはよく使ってましたね。

正規表現を定義する

他のファイルでも使えるようにグローバルな変数が欲しいと思ってRiverpodを使おうと思ったが、別ファイルに書いてあるconstを呼び出すこともできるのだけれど、シングルトンで書いてみたかったのでグローバルに値を使えるように定義してみた。

コンストラクターに、_(アンダースコア)をつけるとシングルトンになります。JavaやTypeScriptだと、privateっていうキーワードがありますけど、Dartにはないので、_を変数や関数につけて、プライベートにすることがあります。

シングルトン
// 他のページでも使うかもしれないのでシングルトンでグローバルに正規表現を定義
class RegularExpression {
  late String kana;
  late String password;

  static final RegularExpression _instance = RegularExpression._internal();

  factory RegularExpression() => _instance;

  RegularExpression._internal() {
    // カタカナの正規表現
    kana = r'^[ァ-ヶー]+$';
    // 半角数値のみの正規表現
    password = r'^[0-9]+$';
  }
}

データを保持するモデルを作る

これはなくてもいいです。データを保持するエンティティという入れ物が欲しいだけで、print文で表示するだけで良いです。riverpodでは使います。
ラジオボタンのデータ型には、enumで定義したGenderを使うのでこれはグローバルに定義してますね。enumにする理由は僕はよくわからないですが、詳しい人によると、typoを防ぐ目的があるのだとか?

モデル
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'user_state.freezed.dart';
part 'user_state.g.dart';

// 性別のenumを引数つきコンストラクタで定義
enum Gender {
  male(value: '男性'),
  woman(value: '女性');

  final String value;
  const Gender({required this.value});
}


class Person with _$Person {
  const factory Person({
    (Gender) gender,
    ('') String kana,
    (0) int password,
  }) = _Person;

  factory Person.fromJson(Map<String, Object?> json)
      => _$PersonFromJson(json);
}

StatefulWidgetを使う

よくあるパターンですね。ラジオボタンと入力フォームを使って、入力するときの状態管理をやってみました。状態管理は、setStateでおこなっています。
エラーが出たら画面が更新されてエラー処理をします。いっぱいコードが書いてあるのでクラスが肥大化して冗長かなと思います。でも意外と開発現場でもこんなコードあるんですよね。
Riverpod使ってても実は、ref.listenをいっぱいかいてるパターンもあったりする😅

今回作成したサンプルは、ラジオボタンは切り替わるだけですが、入力フォームは12文字までしか入力できなくて、カナと半角数字しか入力できないようになっております💁
HookWidgetもRiverpodも同じバリデーションを使ってます。

StatefulWidgetなForm
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:form_example/const/regular_expression.dart';
import 'package:form_example/model/user_state.dart';

class StatefulForm extends StatefulWidget {
  const StatefulForm({super.key});

  
  State<StatefulForm> createState() => _StatefulFormState();
}

class _StatefulFormState extends State<StatefulForm> {
  // 性別のラジオボタンの状態変数
  Gender? _gender = Gender.male;
  // 一意なキーを参照するためのグローバルキー
  final _formKey = GlobalKey<FormState>();
  // エラーメッセージの状態変数
  String _errorMessage = '';
  // 数字のエラーメッセージの状態変数
  String _numberErrorMessage = '';
  // form用のコントローラー
  final _kanaController = TextEditingController();
  final _passwordController = TextEditingController();

  // コントローラーの破棄
  
  void dispose() {
    _kanaController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.green,
        title: const Text('Stateful Form'),
      ),
      // Formでラップすることで、Formの子要素のTextFormFieldのvalidatorを使える
      body: Form(
        key: _formKey,
        child: Column(
          children: [
            // ラジオボタンを配置
            ListTile(
              title: Text(Gender.male.value),
              leading: Radio<Gender>(
                value: Gender.male,
                groupValue: _gender,
                onChanged: (Gender? value) {
                  setState(() {
                    _gender = value;
                  });
                },
              ),
            ),
            ListTile(
              title: Text(Gender.woman.value),
              leading: Radio<Gender>(
                value: Gender.woman,
                groupValue: _gender,
                onChanged: (Gender? value) {
                  setState(() {
                    _gender = value;
                  });
                },
              ),
            ),
            const Padding(
              padding: EdgeInsets.only(left: 20),
              child: Align(
                  alignment: Alignment.centerLeft, child: Text('12文字まで入力可能')),
            ),
            const SizedBox(height: 20),
            Container(
              width: 300,
              decoration: BoxDecoration(
                border: Border.all(color: Colors.grey),
              ),
              child: TextFormField(
                controller: _kanaController,
                inputFormatters: [LengthLimitingTextInputFormatter(12)],
                validator: (value) {
                  final regExp = RegExp(RegularExpression().kana);
                  if (!regExp.hasMatch(value!)) {
                    setState(() {
                      _errorMessage = 'カタカナのみ入力できます';
                    });
                    return '';
                  }
                  setState(() {
                    _errorMessage = '';
                  });
                  return null;
                },
              ),
            ),
            const SizedBox(height: 10),
            Text(
              _errorMessage,
              style: const TextStyle(color: Colors.red),
            ),
            const SizedBox(height: 20),
            Container(
              width: 300,
              decoration: BoxDecoration(
                border: Border.all(color: Colors.grey),
              ),
              child: TextFormField(
                controller: _passwordController,
                inputFormatters: [LengthLimitingTextInputFormatter(12)],
                validator: (value) {
                  final regExp = RegExp(RegularExpression().password);
                  if (!regExp.hasMatch(value!)) {
                    setState(() {
                      _numberErrorMessage = '半角数字のみ入力できます';
                    });
                    return '';
                  }
                  setState(() {
                    _numberErrorMessage = '';
                  });
                  return null;
                },
              ),
            ),
            const SizedBox(height: 10),
            Text(
              _numberErrorMessage,
              style: const TextStyle(color: Colors.red),
            ),
            const SizedBox(height: 5),
            ElevatedButton(
                onPressed: () {
                  if (_formKey.currentState!.validate()) {
                    var users = Person().copyWith(
                      gender: _gender!,
                      kana: _kanaController.text,
                      password: int.parse(_passwordController.text),
                    );
                    debugPrint(users.toString());
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('送信に成功しました!')),
                    );
                  }
                },
                child: const Text('送信')),
          ],
        ),
      ),
    );
  }
}

HookWidgetを使う

私が好んで使っているflutter_hooksを使ってみようと思います。これは開発者のRemiさんがReactも使える人らしいく、その影響で作ったぽいです?

setStateの原型ってこのuseStateみたいです。flutter_hooksでもこれが使えます。やることは一緒で画面が更新されて保存されている値が変更されるというイメージでしょうか...
足し算するとか、文字を上書きする、オブジェクの表示・非表示をする目的で使います。

カウンターアプリの例

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      You pressed me {count} times
    </button>
  );
}

これがHookWidgetを使った例だと、useStateuseTextEditingControllerで値をモデルクラスに渡しています。状態の管理は、useStateでおこないonChangedで入力された値によってエラー処理をします。

HookWidget
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_example/const/regular_expression.dart';
import 'package:form_example/model/user_state.dart';

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

  
  Widget build(BuildContext context) {
    // 性別のラジオボタンの状態変数
    final _gender = useState<Gender?>(Gender.male);
    // 一意なキーを参照するためのグローバルキー
    final _formKey = useMemoized(() => GlobalKey<FormState>());
    // エラーメッセージの状態変数
    final _errorMessage = useState<String>('');
    // 数字のエラーメッセージの状態変数
    final _numberErrorMessage = useState<String>('');
    // form用のコントローラー
    final _kanaController = useTextEditingController();
    final _passwordController = useTextEditingController();

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.red,
        title: const Text('Hook Form'),
      ),
      body: Form(
        key: _formKey,
        child: Column(
          children: [
            // ラジオボタンを配置
            ListTile(
              title: Text(Gender.male.value),
              leading: Radio<Gender>(
                value: Gender.male,
                groupValue: _gender.value,
                onChanged: (Gender? value) {
                  _gender.value = value;
                },
              ),
            ),
            ListTile(
              title: Text(Gender.woman.value),
              leading: Radio<Gender>(
                value: Gender.woman,
                groupValue: _gender.value,
                onChanged: (Gender? value) {
                  _gender.value = value;
                },
              ),
            ),
            const Padding(
              padding: EdgeInsets.only(left: 20),
              child: Align(
                  alignment: Alignment.centerLeft, child: Text('12文字まで入力可能')),
            ),
            const SizedBox(height: 20),
            // TextFormFieldを配置
            TextFormField(
              controller: _kanaController,
              inputFormatters: [LengthLimitingTextInputFormatter(12)],
              // 入力された値を取得するためのコールバック
              onChanged: (String value) {
                // 入力された値が12文字を超えた場合
                if (value.length > 12) {
                  // エラーメッセージを表示
                  _errorMessage.value = '12文字以内で入力してください';
                } else {
                  // エラーメッセージを非表示
                  _errorMessage.value = '';
                }
              },
              // 入力された値が正規表現にマッチしなかった場合
              validator: (String? value) {
                if (value != null) {
                  if (!RegExp(RegularExpression().kana).hasMatch(value)) {
                    // エラーメッセージを表示
                    return 'カタカナで入力してください';
                  }
                }
                return null;
              },
            ),
            // パスワードの入力フォーム
            TextFormField(
              controller: _passwordController,
              inputFormatters: [LengthLimitingTextInputFormatter(12)],
              // 入力された値を取得するためのコールバック
              onChanged: (String value) {
                // 入力された値が12文字を超えた場合
                if (value.length > 12) {
                  // エラーメッセージを表示
                  _numberErrorMessage.value = '12文字以内で入力してください';
                } else {
                  // エラーメッセージを非表示
                  _numberErrorMessage.value = '';
                }
              },
              // 入力された値が正規表現にマッチしなかった場合
              validator: (String? value) {
                if (value != null) {
                  if (!RegExp(RegularExpression().password).hasMatch(value)) {
                    // エラーメッセージを表示
                    return '半角数字のみで入力してください';
                  }
                }
                return null;
              },
            ),
            ElevatedButton(
                onPressed: () {
                  if (_formKey.currentState!.validate()) {
                    var users = Person().copyWith(
                      gender: _gender.value,
                      kana: _kanaController.text,
                      password: int.parse(_passwordController.text),
                    );
                    debugPrint('ユーザーの情報を保存しました: $users');
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('送信に成功しました!')),
                    );
                  }
                },
                child: const Text('送信')),
          ],
        ),
      ),
    );
  }
}

Riverpodを使った例

今回は、hooks_riverpodを使っているので、全部プロバイダーで状態管理をしていないです。Notifierもラジオボタン用と、入力フォーム用に分けました。
分けないと良い感じで使えませんでした💦

Notifierクラス
import 'package:form_example/model/user_state.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'form_notifier.g.dart';


class GenderNotifier extends _$GenderNotifier {
  
  Gender build() {
    return Gender.male;
  }

  void updateGender(Gender gender) {
    state = gender;
  }
}


class FormNotifier extends _$FormNotifier {
  
  Person build() {
    return const Person();
  }

  void kanaChanged(String value) {
    state = state.copyWith(kana: value);
  }

  void passwordChanged(String value) {
    state = state.copyWith(password: int.parse(value));
  }
}

Freezedをデータ型に使い、onChangedのコールバックの中で、ref.readNotifierからメソッドを呼び出して、copyWithでオブジェクトをコピーしてそこに外から引数で値を渡しています。
stateってのは、状態の変数のことで、これはどれなのかというと、Notifierのデータ型に使ったFreezedです。
int使ったときは、多分0かな。Stringだったら空文字かもしれない''。boolだったら、true or falseかな。

stateの中身は、ページ上のPersonってクラス名のFreezedです。これが理解できてれば、riverpodの公式ドキュメンントのTodoアプリの仕組みはわかると思う。
stateList...の組み合わせです。Dartの基本的な知識が詰まってます。

HookConsumerWidgetを使った例
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_example/const/regular_expression.dart';
import 'package:form_example/model/user_state.dart';
import 'package:form_example/usecase/form_notifier.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class FormConsumer extends HookConsumerWidget {
  const FormConsumer({super.key});
  
  Widget build(BuildContext context, WidgetRef ref) {
    // 一意なキーを参照するためのグローバルキー
    final _formKey = useMemoized(() => GlobalKey<FormState>());
    final genderNotifier = ref.watch(genderNotifierProvider);
    // form用のコントローラー
    final _kanaController = useTextEditingController();
    final _passwordController = useTextEditingController();

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.lightBlue,
        title: const Text('Form Consumer'),
      ),
      body: Form(
        key: _formKey,
        child: Column(
          children: [
            // ラジオボタンを配置
            ListTile(
              title: Text(Gender.male.value),
              leading: Radio<Gender>(
                value: Gender.male,
                groupValue: genderNotifier,
                onChanged: (Gender? value) {
                  ref
                      .read(genderNotifierProvider.notifier)
                      .updateGender(value!);
                },
              ),
            ),
            ListTile(
              title: Text(Gender.woman.value),
              leading: Radio<Gender>(
                value: Gender.woman,
                groupValue: genderNotifier,
                onChanged: (Gender? value) {
                  ref
                      .read(genderNotifierProvider.notifier)
                      .updateGender(value!);
                },
              ),
            ),
            const Padding(
              padding: EdgeInsets.only(left: 20),
              child: Align(
                  alignment: Alignment.centerLeft, child: Text('12文字まで入力可能')),
            ),
            const SizedBox(height: 20),
            // TextFormFieldを配置
            TextFormField(
              controller: _kanaController,
              inputFormatters: [LengthLimitingTextInputFormatter(12)],
              // 入力された値を取得するためのコールバック
              onChanged: (String value) {
                // 入力された値が12文字を超えた場合
                if (value.length > 12) {
                  // エラーメッセージを表示
                  // _errorMessage.value = '12文字以内で入力してください';
                  ref.read(formNotifierProvider.notifier).kanaChanged(value);
                } else {
                  // エラーメッセージを非表示
                  ref.read(formNotifierProvider.notifier).kanaChanged(value);
                }
              },
              // 入力された値が正規表現にマッチしなかった場合
              validator: (String? value) {
                if (value != null) {
                  if (!RegExp(RegularExpression().kana).hasMatch(value)) {
                    // エラーメッセージを表示
                    return 'カタカナで入力してください';
                  }
                }
                return null;
              },
            ),
            // パスワードの入力フォーム
            TextFormField(
              controller: _passwordController,
              inputFormatters: [LengthLimitingTextInputFormatter(12)],
              // 入力された値を取得するためのコールバック
              onChanged: (String value) {
                // 入力された値が12文字を超えた場合
                if (value.length > 12) {
                  // エラーメッセージを表示
                  ref
                      .read(formNotifierProvider.notifier)
                      .passwordChanged(value);
                } else {
                  // エラーメッセージを非表示
                  ref
                      .read(formNotifierProvider.notifier)
                      .passwordChanged(value);
                }
              },
              // 入力された値が正規表現にマッチしなかった場合
              validator: (String? value) {
                if (value != null) {
                  if (!RegExp(RegularExpression().password).hasMatch(value)) {
                    // エラーメッセージを表示
                    return '半角数字のみで入力してください';
                  }
                }
                return null;
              },
            ),
            ElevatedButton(
                onPressed: () {
                  if (_formKey.currentState!.validate()) {
                    var users = const Person().copyWith(
                      gender: genderNotifier.value,
                      kana: _kanaController.text,
                      password: int.parse(_passwordController.text),
                    );
                    debugPrint('Riverpodで保存しました: $users');
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('送信に成功しました!')),
                    );
                  }
                },
                child: const Text('送信')),
          ],
        ),
      ),
    );
  }
}

感想

Flutterと付き合い続けてもう2年半以上になりましたね〜
まだまだライフサイクルとか、デザインパターンとかメモリとか知らない単語が多すぎるのと、Flutterが進化するのが早すぎて追いつくのが大変ですが、物作りが好きな人なので使い続けたい技術です。

最後に筆者が最近携わった案件についてざっくり書いておきます。最近携わったSESの案件は、設計パターンやriverpodFreezedの色々な使い方を学べてすごく勉強になりました。普段書いてるソースコードにいかしてますね。

  • 大手Sierにて、エネルギーに関係したサービスをFlutter Webで開発
  • フィットネスアプリの開発

こちらが完成品です:
https://github.com/sakurakotubaki/FormStatePattern

Discussion