flutter_hook_form 2.0.1
flutter_hook_form: ^2.0.1 copied to clipboard
A Flutter package that provides a hook to manage form fields.
flutter_hook_form #
A type-safe form controller for Flutter applications using hooks. This package provides a flexible and type-safe way to handle form validation and state management in Flutter. Inspired by react_hook_form package.
Motivation #
Managing forms in Flutter can be challenging, especially when dealing with multiple form fields and their controllers. The traditional approach requires creating and managing individual controllers for each field, which can lead to boilerplate code and reduced maintainability. flutter_hook_form
was created to address these challenges by providing a more streamlined and declarative way to handle forms, making the form configuration process more intuitive and maintainable. Unlike other form solutions that introduce new widgets and require significant refactoring, flutter_hook_form
is designed to work seamlessly with Flutter's built-in form widgets. This means you can gradually adopt it in your existing forms without having to rewrite your entire form structure or learn new widget patterns.
The benefits of using flutter_hook_form
are clear:
- ๐งน Reduced Boilerplate: No need to manually create and dispose controllers
- ๐ Type Safety: Form fields are type-safe and validated at compile time
- โป๏ธ Reusable Validation: Built-in validators and easy custom validation
- ๐ Cleaner Code: Form logic is separated into a schema class
- ๐ฎ Better State Management: Form state is handled automatically
- ๐ Internationalization Ready: Built-in support for translated error messages
Table of Contents #
Getting Started #
Add this to your package's pubspec.yaml
file:
dependencies:
flutter_hook_form: ^1.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: ^1.0.0
# Not essential but recommended, for different dependency injection
# see "Alternative Injection Methods" paragraph.
flutter_hooks: ^0.20.0
If you plan to use code generation (recommended), also add:
dev_dependencies:
build_runner: ^2.4.0
Create your Schema #
The package includes a code generator that helps you define form schemas using annotations. This approach reduces boilerplate and provides better type safety.
Define the schema with the @HookFormSchema()
annotation and each fields with a @HookFormField()
annotation:
import 'package:flutter_hook_form/flutter_hook_form.dart';
part 'signin_form.schema.dart';
@HookFormSchema()
class SignInFormSchema extends _SignInFormSchema {
SignInFormSchema(); //<- don't forget to pass fields to super constructor
@HookFormField<String>(validators: [
RequiredValidator<String>(),
EmailValidator(),
])
static const email = _SignInFormSchema.email;
@HookFormField<String>(validators: [
RequiredValidator<String>(),
MinLengthValidator(8),
])
static const password = _SignInFormSchema.password;
static Set<InitializedField<SignInFormSchema, dynamic>> initWith(
String? email,
String? password,
) {
return _SignInFormSchema.initializeWith(
email: email,
password: password,
);
}
}
After defining your schema:
- Run
flutter pub run build_runner build
to generate the code - The generator will create the
_SignInFormSchema
class and field schema classes - Make sure to pass all static fields to the super constructor as shown above
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(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'}) |
๐จ Important: When using multiple validators, they are executed in the order they are defined in the list.
Create validators
You can create custom validators by extending the Validator
class. To support Internationalization, return the defined
errorCode
on error. For more info about 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; // <- needed for Internationalization
}
return null;
};
}
// Use in your form schema
@HookFormField<String>(validators: [
RequiredValidator<String>(),
UsernameValidator(),
])
static const username = _UsernameFieldSchema();
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; // <- needed for Internationalization
}
return null;
};
}
Use "Hooked" widgets #
flutter_hook_form
includes a set of convenient Form widgets to streamline your development process.
Note that these widgets are entirely optional and simply wrap Flutter's standard form widgets. This package is designed to be highly customizable and adaptable to your specific needs.
Use form controller
To use the useForm
hook, you need to install flutter_hooks
in your project. The useForm
hook can only be used within a HookWidget
or a widget that uses the HookConsumerWidget
mixin.
// โ
Correct usage
class MyForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm(formSchema: MyFormSchema());
// ...
}
}
// โ
Also correct with Riverpod
class MyForm extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = useForm(formSchema: MyFormSchema());
// ...
}
}
// โ Incorrect usage
class MyForm extends StatelessWidget {
@override
Widget build(BuildContext context) {
final form = useForm(formSchema: MyFormSchema()); // This will not work
// ...
}
}
If you need to use the form controller in a regular widget, you can either:
- Use the
FormFieldsController
directly - Access it through a provider using
useFormContext
(HookWidget
not necessary here as it is not a hook.) - 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(
fieldHook: SignInFormSchema.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(
fieldHook: SignInFormSchema.rememberMe,
builder: ({value, onChanged, error}) {
return Checkbox(
value: value,
onChanged: onChanged,
);
},
)
Here's a complete example of a form using these widgets:
class SignInForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm(
formSchema: SignInFormSchema()
// You can define initial values here if needed
initialValues: SignInFormSchema.initializeWith(
email: 'user@example.com',
password: '',
)
);
return HookedForm(
form: form, // Bind the form controller with the Form widget
child: Column(
children: [
// No need to specify the form schema type - it's inferred from the fieldHook
HookedTextFormField(
fieldHook: SignInFormSchema.email,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
),
),
HookedTextFormField(
fieldHook: SignInFormSchema.password,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
),
),
// No need to specify the form schema type or value type - both are inferred
HookedFormField(
fieldHook: SignInFormSchema.rememberMe,
builder: (field) {
return Checkbox(
value: field.value,
onChanged: (value) => field.didChange(value),
);
},
),
ElevatedButton(
onPressed: () {
if (form.validate()) {
// Form is valid, get all values
final values = form.getValues();
print('Email: ${values[SignInFormSchema.email]}');
print('Password: ${values[SignInFormSchema.password]}');
}
},
child: const Text('Sign In'),
),
],
),
);
}
}
Form initialization
When you want to initialized your form with a value, you can either use the generated method from the .schema.dart
or assign to each HookField
of your choice a value :
// If you use generated schema
final form = useForm(
formSchema: SignInFormSchema()
// You can define initial values here if needed. This function is from the generated class _SignInFormSchema and the
// SignInFormSchema just redirect to this function
initialValues: SignInFormSchema.initializeWith(
email: 'user@example.com',
password: '',
)
);
// If you don't use generated schema, simply use the extension method on HookField `withInitialValue`.
final form = useForm(
formSchema: SignInFormSchema()
initialValues: {
SignInFormSchema.email.withInitialValue('user@example.com'),
SignInFormSchema.password.withInitialValue(''),
}
);
Form State Management
The form controller provides several methods to manage form state:
// Update a field value
form.updateValue(SignInFormSchema.email, 'new@email.com');
// Get a field value
final email = form.getValue(SignInFormSchema.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 a field has been modified
final isDirty = form.isDirty(SignInFormSchema.email);
// Check if a specific field is valid
final isEmailValid = form.validateField(SignInFormSchema.email);
// Get field error message
final error = form.getFieldError(SignInFormSchema.email);
Customizations #
Custom Validation Messages & Internationalization #
flutter_hook_form comes with validators messages customization. Simply override the FormErrorMessages
class and provide it via the HookFormScope
. This allows you to translate error messages that appear in forms.
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) {
final error = switch (errorCode) {
// Same error code used in the MinAgeValidator definition
'min_age_error' when value is int => minAgeError(value),
_ => null,
};
return error;
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, child) => HookFormScope(
messages: CustomFormMessages(context),
child: child ?? const SignInPage(),
),
);
}
}
The parseErrorCode
method is the key component for custom error message handling. It maps your validator error codes to their corresponding translated messages.
Form Injection and Context Access #
flutter_hook_form provides a way to inject and access form controllers throughout your widget tree using the HookedForm
widget and useFormContext
hook. Use the HookedForm
to inject the form
in the widget tree and then retrieve the instance with the useFormContext
in child widget.
Remember that you don't need useFormContext
if you use a "Hooked" widget.
class ParentWidget extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm(formSchema: SignInFormSchema());
return HookedForm(
form: form,
child: Column(
children: [
// Child widgets can access the form using useFormContext
const ChildWidget(),
],
),
);
}
}
class ChildWidget extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useFormContext<SignInFormSchema>();
return ///... child widget
}
}
Alternative Injection Methods #
While HookedForm
is the recommended way to inject form controllers, you can also use any other dependency injection method or package. Here are some examples:
Using Riverpod
final signInFormProvider = Provider<FormFieldsController<SignInFormSchema>>((ref) {
return FormFieldsController(
GlobalKey<FormState>(),
SignInFormSchema(),
);
});
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<SignInFormSchema>>(
() => FormFieldsController(
GlobalKey<FormState>(),
SignInFormSchema(),
),
);
}
class SignInForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = getIt<FormFieldsController<SignInFormSchema>>();
return HookedForm(
form: form,
child: // ... form fields
);
}
}
Using Provider
class SignInForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = FormFieldsController(
GlobalKey<FormState>(),
SignInFormSchema(),
);
return ChangeNotifierProvider.value(
value: form,
child: HookedForm(
form: form,
child: // ... form fields
),
);
}
}
Navigating with the form instance
In case of a navigation, a new widget tree is generated and you may loose the access to the form instance, resulting in an
error when using the userFormContext
. To avoid this, use a HookedFormProvider
on top of your new widget tree to provide
the created form :
showBottomSheet(
context: context,
builder: (context) {
return HookedFormProvider(
form: form,
child: const MySubForm(),
);
},
);
Use schema without code generation #
If you prefer not to use code generation, you can define your form schema manually:
import 'package:flutter_hook_form/flutter_hook_form.dart';
class SignInFormSchema extends FormSchema {
SignInFormSchema();
// The form schema type is included in the HookField
static const HookedFieldId email = HookedField<SignInFormSchema, String>(
'email',
validators: [
RequiredValidator(),
EmailValidator(),
]
);
static const password = HookedField<SignInFormSchema, String>(
'password',
validators: [
RequiredValidator<String>(),
MinLengthValidator(8),
],
);
@override
Set<HookField<FormSchema, dynamic>> get fields => {
email,
password,
};
}
Write your own Form field #
"Hooked" widgets are here to facilitate form development, but you can write your own form fields to fit your specific needs. Behind the scenes, HookedFormField
and HookedTextFormField
are simply wrappers around Flutter's standard FormField
and TextFormField
that connect them to the form controller.
To create your own custom form field, you need to:
- Connect to the form controller (either via
useFormContext
or by passing it directly) - Use the correct field ID from your schema
- Handle validation and error display
- Register value changes with the form controller
Here's a simple example of a custom checkbox form field:
class CustomCheckboxField<F extends FormSchema> extends HookWidget {
const CustomCheckboxField({
Key? key,
required this.fieldHook,
this.initialValue = false,
required this.label,
}) : super(key: key);
final HookedFieldId<F, bool> fieldHook;
final bool initialValue;
final String label;
@override
Widget build(BuildContext context) {
// Get the form controller from context
final form = useFormContext<F>();
return FormField<bool>(
// Connect the field to the form using the fieldHook
key: form.getFieldKey(fieldHook),
validator: (_) => form.getFieldError(fieldHook),
builder: (field) {
return Row(
children: [
Checkbox(
value: field.value ?? false,
onChanged: (value) {
// Update the field value
field.didChange(value);
// Notify the form controller about the change
form.registerFieldChange(fieldHook, value);
},
),
Text(label),
if (field.hasError)
Text(
field.errorText!,
style: TextStyle(color: Colors.red),
),
],
);
},
);
}
}
For more complex implementations, refer to the source code of:
The key aspects to remember when creating custom form fields:
- Use
form.getFieldKey(fieldHook)
to connect the field to the form controller - Use
form.getFieldError(fieldHook)
for validation - Call
form.registerFieldChange(fieldHook, newValue)
when the value changes - Handle the display of error messages appropriately
Use Cases #
Form Value Handling and Payload Conversion #
One of the main pain points in Flutter forms is handling form values and converting them to the correct payload format. flutter_hook_form makes this process much easier by allowing you to define static methods in your form schema for validation and payload conversion.
@HookFormSchema()
class SignInFormSchema extends _SignInFormSchema {
SignInFormSchema();
@HookFormField<String>(validators: [
RequiredValidator<String>(),
EmailValidator(),
])
static const email = _SignInFormSchema.email;
@HookFormField<String>(validators: [
RequiredValidator<String>(),
MinLengthValidator(8),
])
static const password = _SignInFormSchema.password;
// Static method to validate and convert form values to API payload
static SignInPayload toPayload(FormFieldsController form) {
if(!form.validate()){
return null;
}
return SignInPayload(
email: form.getValue(email)!,
password: form.getValue(password)!,
);
}
}
// Usage in your widget
class SignInForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm(formSchema: SignInFormSchema());
return Form(
key: form.key,
child: Column(
children: [
// ... form fields
ElevatedButton(
onPressed: () {
if (form.validate()) {
final payload = SignInFormSchema.toPayload(form);
// Send payload to API
}
},
child: const Text('Sign In'),
),
],
),
);
}
}
This approach provides several benefits:
- Type-safe form value handling
- Centralized validation logic
- Easy payload conversion
- Reusable form schemas
- Clear separation of concerns
Asynchronous Form Validation #
Flutter's built-in form validation is synchronous, but real-world applications often require asynchronous validation, such as checking if a username is already taken or validating an address with an API.
flutter_hook_form
supports asynchronous validation through the setError
method on the form controller.
Here's how to implement asynchronous validation:
class RegistrationForm extends HookWidget {
@override
Widget build(BuildContext context) {
final form = useForm(formSchema: RegistrationFormSchema());
final isLoading = useState(false);
Future<void> validateUsernameAsync(String username) async {
// Skip validation if empty (let the required validator handle it)
if (username.isEmpty) return;
isLoading.value = true;
try {
// Call your API to check if username exists
final exists = await userRepository.checkUsernameExists(username);
if (exists) {
// Set error manually if username is taken
form.setError(RegistrationFormSchema.username, 'Username is already taken');
}
} finally {
isLoading.value = false;
}
}
return HookedForm(
form: form,
child: Column(
children: [
HookedTextFormField(
fieldHook: RegistrationFormSchema.username,
decoration: InputDecoration(
labelText: 'Username',
suffixIcon: isLoading.value
? const CircularProgressIndicator(strokeWidth: 2)
: null,
),
onChanged: (value) {
// Trigger async validation when the value changes
validateUsernameAsync(value);
},
),
ElevatedButton(
onPressed: () async {
// First perform synchronous validation
if (form.validate()) {
final username = form.getValue(RegistrationFormSchema.username);
// Then perform async validation before submission
await validateUsernameAsync(username);
// Check if any async errors were set
if (!form.hasFieldError(RegistrationFormSchema.username)) {
// No errors, proceed with form submission
submitForm(form);
}
}
},
child: const Text('Register'),
),
],
),
);
}
}
Form Controller Enhancements #
Error Handling and Validation
The FormFieldsController now provides finer control over error handling and validation:
// Set a field error with optional notification control
controller.setError(fieldId, "Error message", notify: false);
// Clear all forced errors
controller.clearForcedErrors(notify: true);
Automatic Form Validation
When using formController.validate()
to automatically validate forms, you might encounter rebuild errors if the widget hasn't finished its initial build cycle. To prevent this and gain more control over validation behavior, use the new optional parameters:
// Validate with custom options
controller.validate(
notify: false, // Prevent listener notifications that would trigger rebuilds
clearErrors: false // Keep existing forced errors instead of clearing them
);
These parameters are particularly useful when validating forms in response to button presses or when implementing conditional form submission logic.
Form State Tracking
New properties help you track form interaction and changes:
// Check if any field has been interacted with by the user
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
}
These state tracking features simplify the implementation of common form patterns like "dirty form" detection and user interaction monitoring.
Additional Information #
Dependencies #
- flutter_hooks: ^0.20.0
- collection: ^1.17.0
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.