formz

DartとFlutterのための統一されたフォーム表現とバリデーション。状態管理と検証ロジックを分離し、BlocやCubitとシームレスに統合できるフォームバリデーションライブラリ

formz

概要

formzは、Dartにおける統一されたフォーム表現を提供するライブラリです。フォームの表現とバリデーションを汎用的な方法で簡素化することを目的としており、Very Good Venturesによって開発されています。

主な特徴

1. 宣言的なフォームバリデーション

FormzInputを拡張してカスタムバリデーターを作成し、型安全な方法でフォームの状態を管理できます。

2. Pure/Dirtyステート管理

  • Pure: 未変更のフォーム入力を表現
  • Dirty: 変更されたフォーム入力を表現

3. エラー表示の制御

displayErrorゲッターを使用して、ユーザーが入力を変更した後にのみエラーを表示できます。

4. パフォーマンス最適化

FormzInputErrorCacheMixinを使用して、高コストなバリデーション結果をキャッシュできます。

基本的な使い方

FormzInputの作成

import 'package:formz/formz.dart';

// バリデーションエラーを定義
enum NameInputError { empty }

// FormzInputを拡張して入力型とエラー型を提供
class NameInput extends FormzInput<String, NameInputError> {
  // super.pureを呼び出して未変更のフォーム入力を表現
  const NameInput.pure() : super.pure('');

  // super.dirtyを呼び出して変更されたフォーム入力を表現
  const NameInput.dirty({String value = ''}) : super.dirty(value);

  // validatorをオーバーライドして入力値の検証を処理
  @override
  NameInputError? validator(String value) {
    return value.isEmpty ? NameInputError.empty : null;
  }
}

FormzInputの操作

const name = NameInput.pure();
print(name.value); // ''
print(name.isValid); // false
print(name.error); // NameInputError.empty
print(name.displayError); // null

const joe = NameInput.dirty(value: 'joe');
print(joe.value); // 'joe'
print(joe.isValid); // true
print(joe.error); // null
print(joe.displayError); // null

高度な使用方法

複数のFormzInputの検証

const validInputs = <FormzInput>[
  NameInput.dirty(value: 'jan'),
  NameInput.dirty(value: 'jen'),
  NameInput.dirty(value: 'joe'),
];

print(Formz.validate(validInputs)); // true

const invalidInputs = <FormzInput>[
  NameInput.dirty(),
  NameInput.dirty(),
  NameInput.dirty(),
];

print(Formz.validate(invalidInputs)); // false

FormzMixinを使用した自動検証

class LoginForm with FormzMixin {
  LoginForm({
    this.username = const Username.pure(),
    this.password = const Password.pure(),
  });

  final Username username;
  final Password password;

  @override
  List<FormzInput> get inputs => [username, password];
}

void main() {
  print(LoginForm().isValid); // false
}

パフォーマンス最適化のためのキャッシュ

import 'package:formz/formz.dart';

enum EmailValidationError { invalid }

class Email extends FormzInput<String, EmailValidationError>
    with FormzInputErrorCacheMixin {
  Email.pure([super.value = '']) : super.pure();
  Email.dirty([super.value = '']) : super.dirty();

  static final _emailRegExp = RegExp(
    r'^[a-zA-Z\d.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z\d-]+(?:\.[a-zA-Z\d-]+)*$',
  );

  @override
  EmailValidationError? validator(String value) {
    return _emailRegExp.hasMatch(value) ? null : EmailValidationError.invalid;
  }
}

BlocやCubitとの統合

FormzSubmissionStatus

フォームの送信状態を管理するための列挙型:

enum FormzSubmissionStatus {
  initial,    // フォームがまだ送信されていない
  inProgress, // フォームが送信中
  success,    // フォームが正常に送信された
  failure,    // フォームの送信に失敗した
  canceled    // フォームの送信がキャンセルされた
}

Cubitでの実装例

class SignUpState with FormzMixin {
  const SignUpState({
    this.email = const Email.pure(),
    this.password = const Password.pure(),
    this.status = FormzSubmissionStatus.initial,
  });

  final Email email;
  final Password password;
  final FormzSubmissionStatus status;

  @override
  List<FormzInput> get inputs => [email, password];

  SignUpState copyWith({
    Email? email,
    Password? password,
    FormzSubmissionStatus? status,
  }) {
    return SignUpState(
      email: email ?? this.email,
      password: password ?? this.password,
      status: status ?? this.status,
    );
  }
}

class SignUpCubit extends Cubit<SignUpState> {
  SignUpCubit(this._authRepository) : super(const SignUpState());

  final AuthRepository _authRepository;

  void emailChanged(String value) {
    final email = Email.dirty(value);
    emit(state.copyWith(
      email: email,
      status: FormzSubmissionStatus.initial,
    ));
  }

  void passwordChanged(String value) {
    final password = Password.dirty(value);
    emit(state.copyWith(
      password: password,
      status: FormzSubmissionStatus.initial,
    ));
  }

  Future<void> signUpFormSubmitted() async {
    if (!state.isValid) return;
    emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
    try {
      await _authRepository.signUp(
        email: state.email.value,
        password: state.password.value,
      );
      emit(state.copyWith(status: FormzSubmissionStatus.success));
    } catch (_) {
      emit(state.copyWith(status: FormzSubmissionStatus.failure));
    }
  }
}

UIでの使用例

class SignUpForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<SignUpCubit, SignUpState>(
      listener: (context, state) {
        if (state.status.isSuccess) {
          Navigator.of(context).pop();
        } else if (state.status.isFailure) {
          ScaffoldMessenger.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(
              const SnackBar(content: Text('サインアップに失敗しました')),
            );
        }
      },
      child: Column(
        children: [
          _EmailInput(),
          const SizedBox(height: 8),
          _PasswordInput(),
          const SizedBox(height: 8),
          _SignUpButton(),
        ],
      ),
    );
  }
}

class _SignUpButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<SignUpCubit, SignUpState>(
      builder: (context, state) {
        return state.status.isInProgress
            ? const CircularProgressIndicator()
            : ElevatedButton(
                onPressed: state.isValid
                    ? () => context.read<SignUpCubit>().signUpFormSubmitted()
                    : null,
                child: const Text('サインアップ'),
              );
      },
    );
  }
}

ベストプラクティス

1. カスタムバリデーターの作成

各入力フィールドに対して専用のFormzInputクラスを作成し、再利用可能なバリデーションロジックを実装します。

2. エラーメッセージの国際化

エラーの表示をUIレイヤーで処理し、多言語対応を実現します:

String? getErrorText(NameInputError? error) {
  switch (error) {
    case NameInputError.empty:
      return '名前を入力してください';
    case null:
      return null;
  }
}

3. 複雑なバリデーション

複数のフィールドに依存するバリデーションは、FormzMixinを使用したフォームクラスで実装します:

class PasswordForm with FormzMixin {
  PasswordForm({
    this.password = const Password.pure(),
    this.confirmedPassword = const ConfirmedPassword.pure(),
  });

  final Password password;
  final ConfirmedPassword confirmedPassword;

  @override
  List<FormzInput> get inputs => [password, confirmedPassword];

  // パスワードの一致を確認
  bool get passwordsMatch => 
    password.value == confirmedPassword.value;
}

4. 非同期バリデーション

APIを使用したバリデーション(例:メールアドレスの重複チェック)は、Cubit/Blocレイヤーで処理します。

まとめ

formzは、Flutterアプリケーションでフォームバリデーションを実装するための強力で柔軟なソリューションを提供します。BlocやCubitとの統合により、複雑なフォームの状態管理も簡潔に実装できます。型安全性と再利用性を重視した設計により、大規模なアプリケーションでも保守しやすいコードを実現できます。