入力フォームの状態管理について考えてみた
対象者
- 入力フォームの状態管理に興味がある人
- StatefulWidget, flutter_hooks, riverpodを使っている
やること/やらないこと
やること:
- 3パターンでFormの状態管理をしてみる
- riverpodだけ、全部プロバイダーではない。私は昔全部プロバイバーにしてた!
やらないこと:
- 入力フォームの状態管理は私の考えでやってるので他人と比較しない
- 何がベストかなと追求しない。これって宗教戦争な気がする
プロジェクトの説明
昔、StatefulWidgetを使うと嫌がられたことがある。それ以来hooks_riverpod
とflutter_hooks
を使い続けてきた...
でもある日hooks_riverpod
とflutter_hooks
使わないように言われた。
それ以来使うのは避けて、ConsumerWidget
とConsumerStatefulWidget
を使っていた。どう使い分けていたかというと、入力フォームのようなそのページでしか状態を扱わない機能が多いページは、ConsumerStatefulWidget
を使い、APIやFireSotreからデータをfetchして表示するだけのページは、ConsumerWidget
でやっていました。
とはいえ、僕はReactチックに使えて案件でも多く使われているhooks_riverpod
とflutter_hooks
を使うのを押し通してますね。
useState
、useMemoized
、useTextEditingController
はよく使ってましたね。
正規表現を定義する
他のファイルでも使えるようにグローバルな変数が欲しいと思って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
を使った例だと、useState
とuseTextEditingController
で値をモデルクラスに渡しています。状態の管理は、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.read
でNotifier
からメソッドを呼び出して、copyWith
でオブジェクトをコピーしてそこに外から引数で値を渡しています。
state
ってのは、状態の変数のことで、これはどれなのかというと、Notifier
のデータ型に使ったFreezed
です。
int
使ったときは、多分0かな。String
だったら空文字かもしれない''
。boolだったら、true or false
かな。
stateの中身は、ページ上のPerson
ってクラス名のFreezed
です。これが理解できてれば、riverpodの公式ドキュメンントのTodoアプリの仕組みはわかると思う。
state
とList
と...
の組み合わせです。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の案件は、設計パターンやriverpod
、Freezed
の色々な使い方を学べてすごく勉強になりました。普段書いてるソースコードにいかしてますね。
- 大手Sierにて、エネルギーに関係したサービスをFlutter Webで開発
- フィットネスアプリの開発
こちらが完成品です:
Discussion