reactive_forms

Flutterアプリケーション向けのモデル駆動型リアクティブフォームバリデーションライブラリ

import { Callout } from "@/components/Callout";

概要

reactive_formsは、Flutterアプリケーション向けの強力なモデル駆動型フォームバリデーションライブラリです。Angular Reactive Formsに強く影響を受けており、時間とともに変化するフォーム入力値を効率的に処理するためのリアクティブなアプローチを提供します。

このライブラリは、ウィジェットの形状、色、アニメーションの決定権を開発者に委ねながら、データの収集と検証の責任から解放します。

主な特徴

1. モデル駆動アプローチ

フォームの構造とバリデーションロジックをウィジェットツリーから分離し、テスタブルで再利用可能なコードを実現します。

2. リアクティブプログラミング

RxDartベースのストリームを使用して、フォーム状態の変更をリアルタイムで監視・反応します。

3. 豊富な組み込みバリデータ

必須入力、メール形式、パターンマッチングなど、20種類以上の組み込みバリデータを提供します。

4. 双方向データバインディング

FormControlとUIウィジェット間の自動的な双方向データ同期を実現します。

インストール

dependencies:
  reactive_forms: ^17.0.0
Flutterバージョンとの互換性: - Flutter 2.8.0未満: reactive_forms <= 10.7.0 - Flutter 2.2.0未満: reactive_forms <= 10.2.0 - Flutter 1.17.0: reactive_forms 7.6.3

基本的な使い方

1. フォームの定義

import 'package:reactive_forms/reactive_forms.dart';

class LoginForm {
  final form = FormGroup({
    'email': FormControl<String>(
      validators: [
        Validators.required,
        Validators.email,
      ],
    ),
    'password': FormControl<String>(
      validators: [
        Validators.required,
        Validators.minLength(8),
      ],
    ),
  });

  FormControl<String> get emailControl => 
      form.control('email') as FormControl<String>;
      
  FormControl<String> get passwordControl => 
      form.control('password') as FormControl<String>;
}

2. UIの実装

class LoginScreen extends StatelessWidget {
  final loginForm = LoginForm();

  @override
  Widget build(BuildContext context) {
    return ReactiveForm(
      formGroup: loginForm.form,
      child: Column(
        children: [
          ReactiveTextField<String>(
            formControlName: 'email',
            decoration: InputDecoration(
              labelText: 'メールアドレス',
              errorText: 'メールアドレスが無効です',
            ),
            validationMessages: {
              ValidationMessage.required: (_) => '必須項目です',
              ValidationMessage.email: (_) => '有効なメールアドレスを入力してください',
            },
          ),
          ReactiveTextField<String>(
            formControlName: 'password',
            obscureText: true,
            decoration: InputDecoration(
              labelText: 'パスワード',
            ),
            validationMessages: {
              ValidationMessage.required: (_) => '必須項目です',
              ValidationMessage.minLength: (_) => '8文字以上入力してください',
            },
          ),
          ReactiveFormConsumer(
            builder: (context, form, child) {
              return ElevatedButton(
                onPressed: form.valid ? () => _login(form) : null,
                child: Text('ログイン'),
              );
            },
          ),
        ],
      ),
    );
  }

  void _login(FormGroup form) {
    final values = form.value;
    print('Email: ${values['email']}');
    print('Password: ${values['password']}');
  }
}

バリデータ

組み込みバリデータ

// 必須
Validators.required

// メール
Validators.email

// 数値範囲
Validators.min(0)
Validators.max(100)

// 文字列長
Validators.minLength(3)
Validators.maxLength(50)

// パターン
Validators.pattern(r'^[0-9]+$')

// 比較
Validators.equals('value')

// 複合バリデータ
Validators.compose([
  Validators.required,
  Validators.minLength(8),
  Validators.pattern(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)'),
])

カスタムバリデータ

Map<String, dynamic>? strongPassword(AbstractControl<dynamic> control) {
  final value = control.value as String?;
  
  if (value == null || value.isEmpty) {
    return null; // 他のバリデータで処理
  }
  
  final hasUpperCase = value.contains(RegExp(r'[A-Z]'));
  final hasLowerCase = value.contains(RegExp(r'[a-z]'));
  final hasDigit = value.contains(RegExp(r'[0-9]'));
  final hasSpecialChar = value.contains(RegExp(r'[!@#$%^&*]'));
  
  if (!hasUpperCase || !hasLowerCase || !hasDigit || !hasSpecialChar) {
    return {'strongPassword': true};
  }
  
  return null;
}

// 使用例
final passwordControl = FormControl<String>(
  validators: [Validators.required, strongPassword],
);

非同期バリデーション

Future<Map<String, dynamic>?> uniqueEmail(
  AbstractControl<dynamic> control,
) async {
  final email = control.value as String?;
  
  if (email == null || email.isEmpty) {
    return null;
  }
  
  // APIでメールアドレスの重複チェック
  final response = await http.get(
    Uri.parse('https://api.example.com/check-email?email=$email'),
  );
  
  if (response.statusCode == 200) {
    final exists = json.decode(response.body)['exists'] as bool;
    return exists ? {'uniqueEmail': true} : null;
  }
  
  return null;
}

// 使用例
final emailControl = FormControl<String>(
  validators: [Validators.required, Validators.email],
  asyncValidators: [uniqueEmail],
);

複雑なフォームの例

ネストされたフォームグループ

class UserRegistrationForm {
  final form = FormGroup({
    'personalInfo': FormGroup({
      'firstName': FormControl<String>(
        validators: [Validators.required],
      ),
      'lastName': FormControl<String>(
        validators: [Validators.required],
      ),
      'birthDate': FormControl<DateTime>(
        validators: [Validators.required],
      ),
    }),
    'contactInfo': FormGroup({
      'email': FormControl<String>(
        validators: [Validators.required, Validators.email],
      ),
      'phone': FormControl<String>(
        validators: [Validators.pattern(r'^\d{10,11}$')],
      ),
    }),
    'address': FormGroup({
      'street': FormControl<String>(),
      'city': FormControl<String>(),
      'zipCode': FormControl<String>(
        validators: [Validators.pattern(r'^\d{3}-\d{4}$')],
      ),
    }),
    'preferences': FormGroup({
      'newsletter': FormControl<bool>(value: false),
      'notifications': FormControl<bool>(value: true),
    }),
  });
}

動的フォーム配列

class SurveyForm {
  final form = FormGroup({
    'questions': FormArray<String>([]),
  });

  FormArray<String> get questions => 
      form.control('questions') as FormArray<String>;

  void addQuestion() {
    questions.add(
      FormControl<String>(validators: [Validators.required]),
    );
  }

  void removeQuestion(int index) {
    questions.removeAt(index);
  }
}

// UI実装
class SurveyWidget extends StatelessWidget {
  final surveyForm = SurveyForm();

  @override
  Widget build(BuildContext context) {
    return ReactiveForm(
      formGroup: surveyForm.form,
      child: Column(
        children: [
          ReactiveFormArray<String>(
            formArrayName: 'questions',
            builder: (context, formArray, child) {
              return Column(
                children: [
                  for (int i = 0; i < formArray.controls.length; i++)
                    Row(
                      children: [
                        Expanded(
                          child: ReactiveTextField<String>(
                            formControl: formArray.controls[i] as FormControl<String>,
                            decoration: InputDecoration(
                              labelText: '質問 ${i + 1}',
                            ),
                          ),
                        ),
                        IconButton(
                          icon: Icon(Icons.delete),
                          onPressed: () => surveyForm.removeQuestion(i),
                        ),
                      ],
                    ),
                ],
              );
            },
          ),
          ElevatedButton(
            onPressed: surveyForm.addQuestion,
            child: Text('質問を追加'),
          ),
        ],
      ),
    );
  }
}

条件付きバリデーション

class ConditionalForm {
  final form = FormGroup({
    'hasAccount': FormControl<bool>(value: false),
    'accountNumber': FormControl<String>(),
  });

  ConditionalForm() {
    // hasAccountがtrueの場合のみaccountNumberを必須にする
    form.control('hasAccount').valueChanges.listen((hasAccount) {
      final accountControl = form.control('accountNumber');
      
      if (hasAccount == true) {
        accountControl.setValidators([
          Validators.required,
          Validators.pattern(r'^\d{10}$'),
        ]);
      } else {
        accountControl.clearValidators();
      }
      
      accountControl.updateValueAndValidity();
    });
  }
}

グローバル設定

void main() {
  runApp(
    ReactiveFormConfig(
      validationMessages: {
        ValidationMessage.required: (error) => '必須項目です',
        ValidationMessage.email: (error) => 'メールアドレスが無効です',
        ValidationMessage.minLength: (error) => 
            '${(error as Map)['requiredLength']}文字以上入力してください',
        ValidationMessage.maxLength: (error) => 
            '${(error as Map)['requiredLength']}文字以下で入力してください',
      },
      child: MyApp(),
    ),
  );
}

高度な機能

フォーム状態の監視

// 値の変更を監視
form.valueChanges.listen((value) {
  print('Form value changed: $value');
});

// 有効性の変更を監視
form.statusChanged.listen((status) {
  print('Form status: $status');
});

// 特定のコントロールを監視
emailControl.valueChanges
  .debounceTime(Duration(milliseconds: 300))
  .listen((value) {
    print('Email changed: $value');
  });

フォームのリセットと初期化

// フォームをリセット
form.reset();

// 特定の値でリセット
form.reset(value: {
  'email': '[email protected]',
  'password': '',
});

// 初期値を設定
form.patchValue({
  'email': '[email protected]',
});

パフォーマンス最適化

// 大規模フォームでのパフォーマンス最適化
ReactiveFormField<String, String>(
  formControlName: 'largeText',
  // リビルドを最小限に抑える
  showErrors: (control) => control.dirty && control.touched,
  builder: (field) {
    return TextField(
      onChanged: field.didChange,
      decoration: InputDecoration(
        errorText: field.errorText,
      ),
    );
  },
);

コード生成

reactive_forms_generatorを使用した型安全なフォーム生成:

// モデル定義
@ReactiveFormAnnotation()
class UserProfile {
  final String name;
  final String email;
  final int age;

  UserProfile({
    @FormControlAnnotation(validators: [RequiredValidator()])
    required this.name,
    @FormControlAnnotation(validators: [RequiredValidator(), EmailValidator()])
    required this.email,
    @FormControlAnnotation(validators: [MinValidator(0), MaxValidator(120)])
    required this.age,
  });
}

// 生成されたフォームを使用
final form = UserProfileForm();
form.nameControl.value = 'John Doe';

まとめ

reactive_formsは、Flutterにおける複雑なフォーム処理を大幅に簡素化する強力なライブラリです。モデル駆動アプローチにより、テスタブルで保守性の高いコードを実現し、豊富な組み込み機能により開発効率を向上させます。