fabrik_forms
Flutter’s Form and TextFormField couple validation to widgets: to test
whether a sign-up form accepts an input, you have to build one.
fabrik_forms keeps form state outside the widget tree. Fields hold values,
validators, and interaction state; widgets subscribe to them. The result is
form logic you can unit test without a WidgetTester.
final form = FabrikForm({ 'email': FabrikField<String>(value: '', validators: [const EmailValidator()]),});
form.update<String>('email', 'not-an-email');form.isValid; // false — no widgets involvedInstallation
Section titled “Installation”dependencies: fabrik_forms: ^0.2.0import 'package:fabrik_forms/fabrik_forms.dart';Quick start
Section titled “Quick start”Define the form, then bind it to your UI:
final formNotifier = FabrikFormNotifier( FabrikForm({ 'email': FabrikField<String>( value: '', validators: [const EmailValidator()], ), 'password': FabrikField<String>( value: '', validators: [const PasswordValidator(requireDigit: true)], ), }),);FabrikFormBuilder( formNotifier: formNotifier, builder: (context, form, get) { final email = get<String>('email');
return Column( children: [ TextField( onChanged: (value) => formNotifier.update<String>('email', value), decoration: InputDecoration( labelText: 'Email', errorText: email.visibleError, ), ), ElevatedButton( onPressed: () { if (form.isValid) { submit(form.values); } else { formNotifier.markAllTouched(); } }, child: const Text('Sign in'), ), ], ); },);Two things are doing quiet work here:
visibleErrorstaysnulluntil the user touches the field, so errors do not appear on a form nobody has typed into yet.markAllTouched()flips every field to touched at once, which is how you reveal all errors when someone submits an incomplete form.
Guides
Section titled “Guides”Mix field types in one form
Section titled “Mix field types in one form”Fields carry their own types, so a single form can hold strings, numbers and
booleans. Declare the type on each field and recover it with get<T>:
final form = FabrikForm({ 'email': FabrikField<String>(value: '', validators: [const EmailValidator()]), 'age': FabrikField<int>( value: 18, validators: [const RangeValidator(min: 18, max: 120)], ), 'subscribed': FabrikField<bool>(value: false),});
final String email = form.get<String>('email').value;final int age = form.get<int>('age').value;Validate across fields
Section titled “Validate across fields”Password confirmation cannot be expressed by a field validator, because a field only sees its own value. Form-level validators see everything:
FabrikForm( { 'password': FabrikField<String>(value: ''), 'confirmPassword': FabrikField<String>(value: ''), }, validators: [ const FieldsMatchValidator( field: 'password', matchField: 'confirmPassword', message: 'Passwords do not match', ), ],);The result belongs to the form rather than to either field:
form.formError; // 'Passwords do not match'form.isValid; // false — form-level rules count toward validityFor one-off rules, FabrikFormRule wraps a function:
FabrikFormRule( (values) => (values['endDate'] as DateTime).isAfter(values['startDate'] as DateTime) ? null : 'End date must be after start date',);Show every failing rule
Section titled “Show every failing rule”error gives the first failure. When several rules should be visible at once —
password requirements being the usual case — use errors:
final password = form.get<String>('password');
Column( children: [ for (final message in password.visibleErrors) Text(message, style: TextStyle(color: context.colors.error)), ],);| Accessor | Returns | Respects touched |
|---|---|---|
error | First failure, or null | no |
errors | Every failure | no |
visibleError | First failure, or null | yes |
visibleErrors | Every failure | yes |
Use the visible* pair in UI and the plain pair in tests and submit logic.
Test a form without widgets
Section titled “Test a form without widgets”Form state is a plain object, so validation logic tests directly:
test('rejects an under-age signup', () { final form = FabrikForm({ 'age': FabrikField<int>( value: 18, validators: [const RangeValidator(min: 18, max: 120)], ), });
form.update<int>('age', 15);
expect(form.isValid, isFalse); expect(form.errors['age'], 'Must be at least 18');});Reset after submitting
Section titled “Reset after submitting”formNotifier.reset();Every field returns to its initial value, and isDirty and isTouched go back
to false across the form — so a freshly reset form does not immediately show
errors.
Write a custom validator
Section titled “Write a custom validator”Extend FabrikValidator<T> and return null when the value is acceptable:
class UsernameValidator extends FabrikValidator<String> { const UsernameValidator();
@override String? call(String value) { if (value.contains(' ')) return 'No spaces allowed'; if (value.length < 3) return 'Too short'; return null; }}Validators are const-constructible and stateless, so a single instance can be
shared across every form that needs it.
API reference
Section titled “API reference”FabrikField<T>
Section titled “FabrikField<T>”| Member | Type | Description |
|---|---|---|
value | T | Current value |
error / errors | String? / List<String> | Validation failures |
visibleError / visibleErrors | String? / List<String> | Failures, once touched |
isValid | bool | No active errors |
isTouched | bool | User has interacted |
isDirty | bool | Differs from the initial value |
update(value) | void | Set value, mark touched, revalidate |
markTouched() | void | Reveal errors without changing the value |
reset() | void | Restore the initial value and clear state |
FabrikForm
Section titled “FabrikForm”| Member | Type | Description |
|---|---|---|
get<T>(key) | FabrikField<T> | Typed field lookup |
update<T>(key, value) | void | Update one field |
values | Map<String, dynamic> | Every current value |
errors | Map<String, String?> | First error per field |
allErrors | Map<String, List<String>> | Every error per field |
formError / formErrors | String? / List<String> | Form-level failures |
isValid / isDirty / isTouched | bool | Aggregate state |
contains(key) / keys | bool / Iterable<String> | Inspect the field set |
markAllTouched() / reset() | void | Form-wide actions |
An unknown key throws an ArgumentError that names the key and lists the
available fields.
FabrikFormNotifier
Section titled “FabrikFormNotifier”A ValueNotifier<FabrikForm> that mirrors the form API and notifies listeners
on update, markAllTouched and reset. Pair it with FabrikFormBuilder,
or listen to it directly.
Built-in validators
Section titled “Built-in validators”| Validator | Validates | Key options |
|---|---|---|
RequiredValidator | Non-empty string | trim, message |
EmailValidator | Email format | isRequired, invalidMessage |
MinLengthValidator | Minimum length | min, message |
MaxLengthValidator | Maximum length | max, message |
PasswordValidator | Complexity rules | minLength, requireUppercase, requireDigit, requireSpecialChar |
UrlValidator | HTTP/HTTPS URLs | requireHttps |
PhoneValidator | Local and international formats | isRequired |
RangeValidator | Numeric range, inclusive | min, max |
PasswordValidator reports length before the character-class rules, so the
most basic failure surfaces first. RangeValidator is generic over num, so
it attaches to FabrikField<int> and FabrikField<double> alike.
Form-level validators
Section titled “Form-level validators”| Validator | Purpose |
|---|---|
FieldsMatchValidator | Two fields must be equal |
FabrikFormRule | Inline rule from a function |
FabrikFormValidator | Base class for custom rules |