flutter_hook_form 1.1.0
flutter_hook_form: ^1.1.0 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() : super(email: email, password: password); //<- don't forget to pass fields to super constructor
@HookFormField<String>(validators: [
RequiredValidator<String>(),
EmailValidator(),
])
static const email = _EmailFieldSchema();
@HookFormField<String>(validators: [
RequiredValidator<String>(),
MinLengthValidator(8),
])
static const password = _PasswordFieldSchema();
}
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,
initialValue: false,
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
// final initialEmailValue = 'user@example.com';
// final initialPasswordValue = '';
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,
// initialValue: initialEmailValue, // Uncomment to use initial values
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
),
),
HookedTextFormField(
fieldHook: SignInFormSchema.password,
// initialValue: initialPasswordValue, // Uncomment to use initial values
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,
initialValue: false,
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'),
),
],
),
);
}
}
Why can't I initialize my form value in the form controller or form schema?
When you need to pre-populate a form, the correct approach is to provide initial values at the widget level in the initialValue
property provided by Flutter FormField
, or update the values after the first build cycle when the form fields have been properly initialized and connected to their keys (not recommended).
This is because form values in flutter_hook_form
are stored in FormFieldState
objects that are associated with GlobalKey
instances, which don't exist until the form is actually built in the widget tree. For more details, see our Form Initialization Guide.
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
),
);
}
}
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()
: super(
fields: {
const FormFieldScheme<String>(
email,
validators: [
RequiredValidator(),
EmailValidator(),
],
),
const FormFieldScheme<String>(
password,
validators: [
RequiredValidator<String>(),
MinLengthValidator(8),
],
),
},
);
// The form schema type is included in the field ID
static const HookedFieldId<SignInFormSchema, String> email = HookedFieldId('email');
static const HookedFieldId<SignInFormSchema, String> password = HookedFieldId('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),
initialValue: initialValue,
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() : super(email: email, password: password);
@HookFormField<String>(validators: [
RequiredValidator<String>(),
EmailValidator(),
])
static const email = _EmailFieldSchema();
@HookFormField<String>(validators: [
RequiredValidator<String>(),
MinLengthValidator(8),
])
static const password = _PasswordFieldSchema();
// 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'),
),
],
),
);
}
}
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.