Skip to content

fabrik_forms

pub.dev

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 involved
dependencies:
fabrik_forms: ^0.2.0
import 'package:fabrik_forms/fabrik_forms.dart';

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:

  • visibleError stays null until 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.

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;

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 validity

For 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',
);

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)),
],
);
AccessorReturnsRespects touched
errorFirst failure, or nullno
errorsEvery failureno
visibleErrorFirst failure, or nullyes
visibleErrorsEvery failureyes

Use the visible* pair in UI and the plain pair in tests and submit logic.

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');
});
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.

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.

MemberTypeDescription
valueTCurrent value
error / errorsString? / List<String>Validation failures
visibleError / visibleErrorsString? / List<String>Failures, once touched
isValidboolNo active errors
isTouchedboolUser has interacted
isDirtyboolDiffers from the initial value
update(value)voidSet value, mark touched, revalidate
markTouched()voidReveal errors without changing the value
reset()voidRestore the initial value and clear state
MemberTypeDescription
get<T>(key)FabrikField<T>Typed field lookup
update<T>(key, value)voidUpdate one field
valuesMap<String, dynamic>Every current value
errorsMap<String, String?>First error per field
allErrorsMap<String, List<String>>Every error per field
formError / formErrorsString? / List<String>Form-level failures
isValid / isDirty / isTouchedboolAggregate state
contains(key) / keysbool / Iterable<String>Inspect the field set
markAllTouched() / reset()voidForm-wide actions

An unknown key throws an ArgumentError that names the key and lists the available fields.

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.

ValidatorValidatesKey options
RequiredValidatorNon-empty stringtrim, message
EmailValidatorEmail formatisRequired, invalidMessage
MinLengthValidatorMinimum lengthmin, message
MaxLengthValidatorMaximum lengthmax, message
PasswordValidatorComplexity rulesminLength, requireUppercase, requireDigit, requireSpecialChar
UrlValidatorHTTP/HTTPS URLsrequireHttps
PhoneValidatorLocal and international formatsisRequired
RangeValidatorNumeric range, inclusivemin, 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.

ValidatorPurpose
FieldsMatchValidatorTwo fields must be equal
FabrikFormRuleInline rule from a function
FabrikFormValidatorBase class for custom rules