flutter_hook_form
A type-safe form controller for Flutter applications using hooks. Inspired by react_hook_form.
What's New in 4.0.0
Version 4.0.0 introduces a significantly simplified API for defining form schemas. The FieldSchema<T> interface now only requires two properties (validators and initialValue), removing the need for boilerplate field name declarations. This makes your enum-based schemas cleaner and more concise while maintaining full type safety.
Motivation
Managing forms in Flutter often requires creating multiple TextEditingController instances, managing their lifecycle, and scattering validation logic across widgets. This package was created to:
- Centralize validation logic: Define all your form fields and their validators in a single enum schema
- Easy access to field values: Get and update field values directly from the form controller without setting up a dedicated state manager or dependency injection
Table of Contents
Getting Started
Add this to your package's pubspec.yaml file:
dependencies:
flutter_hook_form: ^4.0.0
How to use
Install
To use flutter_hook_form, you need to add it to your dependencies in pubspec.yaml:
dependencies:
flutter_hook_form: ^4.0.0
# Required for the useForm hook
flutter_hooks: ^0.20.0
Create your Schema
Define your form schema as an enum implementing FieldSchema<T>. Each enum value represents a form field with its validators and optional initial value.
import 'package:flutter_hook_form/flutter_hook_form.dart';
enum SignInFormFields<T> implements FieldSchema<T> {
email<String>(validators: [RequiredValidator(), EmailValidator()]),
password<String>(validators: [RequiredValidator(), MinLengthValidator(8)]),
rememberMe<bool>();
const SignInFormFields({this.validators});
@override
final List<Validator<T>>? validators;
}
Available Validators
The package comes with several built-in validators:
| Category | Validator | Description | Example |
|---|---|---|---|
| Generic | RequiredValidator<T> |
Ensures field is not empty | RequiredValidator<String>() |
| String | EmailValidator |
Validates email format | EmailValidator() |
MinLengthValidator |
Checks minimum length | MinLengthValidator(8) |
|
MaxLengthValidator |
Checks maximum length | MaxLengthValidator(32) |
|
PhoneValidator |
Validates phone number format | PhoneValidator() |
|
PatternValidator |
Validate the value with the given pattern | PatternValidator(RegExp(r'^[A-zÀ-ú \-]+$')) |
|
| Date | IsAfterValidator |
Validates minimum date | IsAfterValidator(DateTime.now()) |
IsBeforeValidator |
Validates maximum date | IsBeforeValidator(DateTime.now()) |
|
| List | ListMinItemsValidator |
Checks minimum items | ListMinItemsValidator<T>(2) |
ListMaxItemsValidator |
Checks maximum items | ListMaxItemsValidator<T>(5) |
|
| File | MimeTypeValidator |
Validates file type | MimeTypeValidator({'image/jpeg', 'image/png'}) |
| Cross-Field | DateAfterValidator |
Validates date is after another field | DateAfterValidator(field: .startDate) |
MatchesValidator<T> |
Validates value matches another field | MatchesValidator<String>(field: .password) |
When using multiple validators, they are executed in the order they are defined in the list.
Cross-Field Validators
Cross-field validators allow you to validate a field based on the value of another field. They require access to BuildContext to retrieve the other field's value from the form.
enum RegistrationFormFields<T> implements FieldSchema<T> {
password<String>(validators: [RequiredValidator(), MinLengthValidator(8)]),
confirmPassword<String>(validators: [
RequiredValidator(),
MatchesValidator<String>(field: password, message: 'Passwords must match'),
]),
startDate<DateTime>(validators: [RequiredValidator()]),
endDate<DateTime>(validators: [
RequiredValidator(),
DateAfterValidator(field: startDate, message: 'End date must be after start date'),
]);
const RegistrationFormFields({this.validators, this.initialValue});
@override
final List<Validator<T>>? validators;
}
Creating Custom Cross-Field Validators
You can create custom cross-field validators by extending the CrossFieldValidator class:
class PasswordStrengthValidator extends CrossFieldValidator<String> {
const PasswordStrengthValidator({required super.field, super.message})
: super(errorCode: 'password_too_similar');
@override
CrossFieldValidatorFn<String> get validator {
return (value, context) {
if (value == null) return null;
final form = useFormContext<FieldSchema>(context);
final usernameValue = form.getValue<String>(field);
if (usernameValue != null && value.contains(usernameValue)) {
return message ?? errorCode;
}
return null;
};
}
}
// Usage
enum SecurityFormFields<T> implements FieldSchema<T> {
username<String>(validators: [RequiredValidator()]),
password<String>(validators: [
RequiredValidator(),
PasswordStrengthValidator(
field: username,
message: 'Password cannot contain your username',
),
]);
// ...
}
Create validators
You can create custom validators by extending the Validator class. Return the errorCode on error to support internationalization (see Custom Validation Messages & Internationalization).
class UsernameValidator extends Validator<String> {
const UsernameValidator() : super(errorCode: 'username_error');
@override
ValidatorFn<String> get validator => (String? value) {
if (value?.contains('@') == true) {
return errorCode;
}
return null;
};
}
// Use in your form schema
enum ProfileFormFields<T> implements FieldSchema<T> {
username<String>(validators: [RequiredValidator(), UsernameValidator()]);
// ...
}
Custom validators can also include additional parameters:
class MinAgeValidator extends Validator<DateTime> {
const MinAgeValidator({required this.minAge}) : super(errorCode: 'min_age_error');
final int minAge;
@override
ValidatorFn<DateTime> get validator => (DateTime? value) {
if (value == null) return null;
final age = DateTime.now().year - value.year;
if (age < minAge) {
return errorCode;
}
return null;
};
}
Use "Hooked" widgets
flutter_hook_form includes convenient Form widgets to streamline your development process. These widgets are optional and simply wrap Flutter's standard form widgets.
Use form controller
The useForm hook requires flutter_hooks and can only be used within a HookWidget or HookConsumerWidget.
// Correct usage
class MyForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm<MyFormFields>();
// ...
}
}
// Also correct with Riverpod
class MyForm extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = useForm<MyFormFields>();
// ...
}
}
If you need to use the form controller in a regular widget, you can either:
- Use the
FormFieldsControllerdirectly - Access it through
useFormContext(see Form Injection and Context Access) - Use any other dependency injection method (see Alternative Injection Methods)
HookedTextFormField
HookedTextFormField is a wrapper around Flutter's TextFormField that integrates with the form controller:
HookedTextFormField<SignInFormFields<String>>(
fieldHook: .email,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
),
)
HookedFormField
HookedFormField is a generic form field that can be used with any type of input:
HookedFormField<SignInFormFields<bool>, bool>(
fieldHook: .rememberMe,
builder: (value, onChanged, error) {
return Checkbox(
value: value ?? false,
onChanged: onChanged,
);
},
)
Here's a complete example of a form using these widgets:
class SignInPage extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm<SignInFormFields>();
return Scaffold(
body: HookedForm(
form: form,
child: Column(
children: [
HookedTextFormField<SignInFormFields<String>>(
fieldHook: .email,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
),
),
HookedTextFormField<SignInFormFields<String>>(
fieldHook: .password,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
),
),
HookedFormField<SignInFormFields<bool>, bool>(
fieldHook: .rememberMe,
builder: (value, onChanged, error) {
return Checkbox(
value: value ?? false,
onChanged: onChanged,
);
},
),
ElevatedButton(
onPressed: () {
if (form.validate()) {
final email = form.getValue(.email);
final password = form.getValue(.password);
print('Email: $email, Password: $password');
}
},
child: const Text('Sign In'),
),
],
),
),
);
}
}
Form initialization
When you want to initialize your form with values, pass them to useForm:
final form = useForm<SignInFormFields>(
initialValues: {
SignInFormFields.email: 'user@example.com',
SignInFormFields.password: '',
},
);
Form State Management
The form controller provides several methods to manage form state:
// Update a field value
form.updateValue(.email, 'new@email.com');
// Get a field value
final email = form.getValue(.email);
// Get all form values
final values = form.getValues();
// Reset the form
form.reset();
// Validate the form
final isValid = form.validate();
Form Field State
You can also access the state of individual form fields:
// Check if fields have been modified
final isDirty = form.isDirty({.email, .password});
// Check if a specific field is valid
final isEmailValid = form.validateField(.email);
// Get field error message
final error = form.getFieldError(.email);
Customizations
Custom Validation Messages & Internationalization
Override the FormErrorMessages class and provide it via HookFormScope to translate error messages.
class CustomFormMessages extends FormErrorMessages {
const CustomFormMessages(this.context);
final BuildContext context;
@override
String get required => 'This field is required.';
@override
String get invalidEmail => AppLocalizations.of(context).invalidEmail;
String minAgeError(int age) => 'You must be $age to use this.';
@override
String? parseErrorCode(String errorCode, dynamic value) {
return switch (errorCode) {
'min_age_error' when value is int => minAgeError(value),
_ => null,
};
}
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, child) => HookFormScope(
messages: CustomFormMessages(context),
child: child ?? const SignInPage(),
),
);
}
}
Form Injection and Context Access
Use HookedForm to inject the form controller into the widget tree and retrieve it with useFormContext in child widgets.
class ParentWidget extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm<SignInFormFields>();
return HookedForm(
form: form,
child: Column(
children: [
const ChildWidget(),
],
),
);
}
}
class ChildWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final form = useFormContext<SignInFormFields>(context);
return // ... child widget
}
}
Navigating with the form instance
When navigating, a new widget tree is generated and you may lose access to the form instance. Use HookedFormProvider to provide the form to the new widget tree:
showBottomSheet(
context: context,
builder: (context) {
return HookedFormProvider(
form: form,
child: const MySubForm(),
);
},
);
Alternative Injection Methods
While HookedForm is the recommended way to inject form controllers, you can also use any other dependency injection method.
Using Riverpod
final signInFormProvider = Provider<FormFieldsController<SignInFormFields>>((ref) {
return FormFieldsController(
GlobalKey<FormState>(),
);
});
class SignInForm extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = ref.watch(signInFormProvider);
return HookedForm(
form: form,
child: // ... form fields
);
}
}
Using GetIt
final getIt = GetIt.instance;
void setupDependencies() {
getIt.registerLazySingleton<FormFieldsController<SignInFormFields>>(
() => FormFieldsController(GlobalKey<FormState>()),
);
}
class SignInForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = getIt<FormFieldsController<SignInFormFields>>();
return HookedForm(
form: form,
child: // ... form fields
);
}
}
Write your own Form field
"Hooked" widgets simply wrap Flutter's standard FormField and TextFormField. You can write your own form fields to fit your specific needs.
To create your own custom form field, you need to:
- Connect to the form controller (either via
useFormContextor by passing it directly) - Use
form.fieldKey(field)to connect the field to the form - Handle validation and error display
- Register value changes with
form.updateValue
Here's an example of a custom checkbox form field:
class CustomCheckboxField extends StatelessWidget {
const CustomCheckboxField({
super.key,
required this.field,
required this.label,
});
final FieldSchema<bool> field;
final String label;
@override
Widget build(BuildContext context) {
final form = useFormContext(context);
return FormField<bool>(
key: form.fieldKey(field),
initialValue: form.getInitialValue(field) ?? false,
builder: (fieldState) {
return Row(
children: [
Checkbox(
value: fieldState.value ?? false,
onChanged: (value) {
fieldState.didChange(value);
form.updateValue(field, value);
},
),
Text(label),
if (fieldState.hasError)
Text(
fieldState.errorText!,
style: const TextStyle(color: Colors.red),
),
],
);
},
);
}
}
Use Cases
Form Value Handling and Payload Conversion
Define static methods in your form schema for validation and payload conversion:
enum SignInFormFields<T> implements FieldSchema<T> {
email<String>(validators: [RequiredValidator(), EmailValidator()]),
password<String>(validators: [RequiredValidator(), MinLengthValidator(8)]);
const SignInFormFields({this.validators});
@override
final List<Validator<T>>? validators;
// Static method to validate and convert form values to API payload
static SignInPayload? toPayload(FormFieldsController<SignInFormFields> form) {
if (!form.validate()) {
return null;
}
return SignInPayload(
email: form.getValue(.email)!,
password: form.getValue(.password)!,
);
}
}
// Usage
ElevatedButton(
onPressed: () {
final payload = SignInFormFields.toPayload(form);
if (payload != null) {
// Send payload to API
}
},
child: const Text('Sign In'),
)
Asynchronous Form Validation
Use the setError method for asynchronous validation:
class RegistrationForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm<RegistrationFormFields>();
final isLoading = useState(false);
Future<void> validateUsernameAsync(String username) async {
if (username.isEmpty) return;
isLoading.value = true;
try {
final exists = await userRepository.checkUsernameExists(username);
if (exists) {
form.setError(RegistrationFormFields.username, 'Username is already taken');
}
} finally {
isLoading.value = false;
}
}
return HookedForm(
form: form,
child: Column(
children: [
HookedTextFormField<RegistrationFormFields>(
fieldHook: .username,
decoration: InputDecoration(
labelText: 'Username',
suffixIcon: isLoading.value
? const CircularProgressIndicator(strokeWidth: 2)
: null,
),
onChanged: (value) => validateUsernameAsync(value),
),
ElevatedButton(
onPressed: () async {
if (form.validate()) {
final username = form.getValue(.username);
await validateUsernameAsync(username!);
if (!form.hasFieldError(.username)) {
submitForm(form);
}
}
},
child: const Text('Register'),
),
],
),
);
}
}
Form Controller Enhancements
Error Handling and Validation
// Set a field error with optional notification control
controller.setError(field, "Error message", notify: false);
// Clear all forced errors
controller.clearForcedErrors(notify: true);
Automatic Form Validation
Control validation behavior to prevent rebuild errors:
controller.validate(
notify: false, // Prevent listener notifications
clearErrors: false // Keep existing forced errors
);
Form State Tracking
// Check if any field has been interacted with
if (controller.hasBeenInteracted) {
// Show confirmation dialog before navigating away
}
// Check if any field value has changed from its initial value
if (controller.hasChanged) {
// Enable the "Save Changes" button
}
Additional Information
Dependencies
- flutter_hooks: ^0.21.3
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Support
If you encounter any issues or have questions, please file an issue on the GitHub repository.