ezy_form 1.0.0
ezy_form: ^1.0.0 copied to clipboard
Easy to handle form in Flutter without any complexity. Easy to use and customize.
Ezy Form - handle forms in Flutter with ease #
A lightweight, headless form-state library for Flutter. No third-party state management — just ChangeNotifier + InheritedNotifier. You bring any widget (TextField, CupertinoTextField, Checkbox, DropdownButtonFormField, third-party inputs, etc.) and ezy_form handles the state, validation, and lifecycle.
Features #
| Feature | Details |
|---|---|
| Typed controls | FormControl<T> for any type — String, int, double, bool, DateTime, custom models |
| Built-in text binding | EzyFormControl provides TextEditingController + FocusNode with auto two-way sync, touched-on-blur, and external write handling (reset, clear, patchValue) |
| Typed text fields | parse / format callbacks for non-String text inputs (e.g. int.tryParse) |
| Form arrays | FormArrayControl<T> for dynamic lists — add, insert, move, remove, addControl |
| Form group arrays | FormGroupArray for arrays of structured objects (addresses, line items) with templateFactory |
| Nested groups | FormGroup nesting with dot-path lookups ('info.firstName') |
| Dynamic controls | addControl / removeControl at runtime for wizard / stepwise forms |
| Sync validators | required, email, minLength, maxLength, minValue, maxValue, pattern, equalTo, compose, composeOr |
| Async validators | Server-side checks with pending state for loading indicators |
| Array validators | arrayValidators for collection-level rules (min items, uniqueness, etc.) |
| Disabled controls | markAsDisabled() / markAsEnabled() — skipped by validation, excluded from values |
| Reset / Clear / Remove | Three distinct verbs: reset() (restore initial), clear() (wipe to null), removeAll() (drop children) |
| Patch & Set | patchValue (lenient, no dirty) and setValue (strict, marks dirty) at control and group level |
| Reactive watchers | EzyFormControlWatcher for single values, EzyFormWatcher with selector for multi-value / computed |
| Headless | No opinionated widgets — use any Flutter input widget |
| Zero dependencies | Built on Flutter's ChangeNotifier + InheritedNotifier only |
Installing #
dependencies:
ezy_form: <latest_version>
import 'package:ezy_form/ezy_form.dart';
Quick start #
1. Declare a form #
final form = FormGroup({
'name': FormControl<String>('Sam', validators: [requiredValidator, minLength(2)]),
'email': FormControl<String>(null, validators: [requiredValidator, emailValidator]),
'age': FormControl<int>(25, validators: [requiredValidator, minValue(18)]),
'gender': FormControl<String>(null, validators: [requiredValidator]),
'agreed': FormControl<bool>(false, validators: [requiredTrueValidator]),
'tags': FormArrayControl<String>(null, validators: [requiredValidator]),
'info': FormGroup({
'firstName': FormControl<String>(null, validators: [requiredValidator]),
'lastName': FormControl<String>(null, validators: [requiredValidator]),
}),
});
All FormControl and FormArrayControl instances must live inside a FormGroup.
2. Wrap with EzyFormWidget #
EzyFormWidget(
formGroup: form,
builder: (context, model) {
// add form fields below
},
)
3. Render fields with EzyFormControl #
EzyFormControl provides a TextEditingController and FocusNode in its builder. For text inputs, wire them up — reset / clear / patchValue will reflect into the field automatically. For non-text inputs, just ignore them.
String text field — controller auto-syncs:
EzyFormControl<String>(
formControlName: 'email',
builder: (context, control, controller, focusNode) => TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
labelText: 'Email',
errorText: control.valid ? null : control.error,
),
),
)
Typed text field (int, double, DateTime, etc.) — supply parse + format:
EzyFormControl<int>(
formControlName: 'age',
parse: int.tryParse,
format: (v) => v?.toString() ?? '',
builder: (context, control, controller, focusNode) => TextField(
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Age',
errorText: control.valid ? null : control.error,
),
),
)
Non-text input (checkbox, dropdown, etc.) — ignore controller / focusNode:
EzyFormControl<bool>(
formControlName: 'agreed',
builder: (context, control, _, __) => CheckboxListTile(
value: control.value ?? false,
onChanged: (v) => control.setValue(v),
title: const Text('I agree'),
),
)
4. Form arrays #
EzyFormArrayControl<String>(
formControlName: 'tags',
builder: (context, arrayControl) => Column(
children: [
TextButton(
onPressed: () => arrayControl.add(),
child: const Text('Add tag'),
),
for (var i = 0; i < (arrayControl.controls?.length ?? 0); i++)
TextField(
onChanged: (v) => arrayControl.controls![i].setValue(v),
),
],
),
)
FormArrayControl supports both per-item validators (propagated to each child) and arrayValidators that run against the whole list:
'tags': FormArrayControl<String>(
null,
validators: [requiredValidator], // each tag must be non-empty
arrayValidators: [
(values) => (values == null || values.length < 2)
? 'Add at least 2 tags'
: null,
],
),
5. Access the form from anywhere below EzyFormWidget #
EzyFormConsumer(
builder: (context, form) {
return ElevatedButton(
onPressed: () {
if (form.validate()) {
print(form.values);
}
},
child: const Text('Submit'),
);
},
)
Reactive value watching #
Use EzyFormControlWatcher to rebuild part of the UI when a control's value changes — for example, conditionally showing a field:
EzyFormControlWatcher<bool>(
formControlName: 'agreed',
builder: (context, agreed) {
if (agreed != true) return const SizedBox.shrink();
return EzyFormControl<String>(
formControlName: 'licenseNumber',
builder: (context, control, controller, focusNode) => TextField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(labelText: 'License number'),
),
);
},
)
The watcher receives only the value (T?), keeping it minimal. It supports dotted paths ('info.age') for nested controls.
Watching multiple controls #
Use EzyFormWatcher with a selector function when you need to react to multiple controls or compute a derived value. Dart records give you full type safety:
EzyFormWatcher(
selector: (form) => (
form.control<bool>('agreed').value,
form.control<String>('name').value,
),
builder: (context, values) {
final (agreed, name) = values;
if (agreed != true) return const SizedBox.shrink();
return Text('Welcome, $name!');
},
)
You can also derive computed values like form validity:
EzyFormWatcher<bool>(
selector: (form) => form.isValid,
builder: (context, isValid) => ElevatedButton(
onPressed: isValid ? () => submit() : null,
child: const Text('Submit'),
),
)
Nested groups #
FormGroup can be nested. Use dot-separated paths to look up controls:
final form = FormGroup({
'info': FormGroup({
'firstName': FormControl<String>(null),
}),
});
// In the widget:
EzyFormControl<String>(
formControlName: 'info.firstName',
builder: (context, control, controller, focusNode) => TextField(
controller: controller,
focusNode: focusNode,
),
)
Validators #
Built-in validators that compose with each other. All factory validators return null on null/empty input so they pair cleanly with requiredValidator:
// Presence
requiredValidator // non-null, non-empty (String, Iterable, Map)
requiredTrueValidator // bool must be true
// String
emailValidator // basic email pattern
minLength(n) // length >= n
maxLength(n) // length <= n
pattern(RegExp, {message}) // regex match
// Numeric (int / double)
minValue(n) // value >= n
maxValue(n) // value <= n
// Cross-control
equalTo(otherControl, {message}) // values must match (e.g. confirm password)
// Compositors
compose([v1, v2, ...]) // AND — first error wins
composeOr([v1, v2, ...]) // OR — null if any passes
Custom validators follow the same String? Function(T? value) contract — return an error message or null. Use them to customize error messages for localization or app-specific rules:
// Custom rule
ValidatorFn<String> noSpaces = (value) {
if (value != null && value.contains(' ')) return 'no spaces allowed';
return null;
};
// Localized — replace built-in validators with your own messages
ValidatorFn<String> pflichtfeld = (value) {
if (value == null || value.isEmpty) return 'Pflichtfeld';
return null;
};
Async validators #
For server-side checks (email uniqueness, username availability), use asyncValidators. They run after sync validators pass:
'email': FormControl<String>(null,
validators: [requiredValidator, emailValidator],
asyncValidators: [
(value) async {
final taken = await api.isEmailTaken(value);
return taken ? 'email already taken' : null;
},
],
),
Use validateAsync() instead of validate() to include async validators:
final isValid = await form.validateAsync();
While async validators run, control.pending is true — use it to show a spinner:
builder: (context, control, controller, focusNode) => TextField(
controller: controller,
suffixIcon: control.pending ? CircularProgressIndicator() : null,
decoration: InputDecoration(
errorText: control.valid ? null : control.error,
helperText: control.pending ? 'checking...' : null,
),
)
Form operations #
Validate #
final isValid = form.validate(); // sync only
final isValid = await form.validateAsync(); // sync + async
Reset (restore initial values) #
form.reset(); // every control returns to its constructor-time value
Clear (wipe to empty) #
form.clear(); // every control value → null, dirty/touched/error cleared
Load from server (patchValue) #
// Partial, lenient, doesn't mark dirty. Unknown keys ignored.
form.patchValue({
'name': 'Loaded Name',
'email': 'loaded@example.com',
'age': 42,
'info': {'firstName': 'Sam', 'lastName': 'D'},
'tags': ['flutter', 'dart'],
});
Strict set (setValue) #
// Requires all keys, marks dirty, throws on unknown/missing keys.
form.setValue({ ... });
Read values #
final map = form.values; // nested Map<String, dynamic> mirroring the group shape
Working with models #
form.values returns a Map<String, dynamic>. Use your model's fromMap / toMap to convert between the form and your domain layer:
// Submit: form values → model → API
if (form.validate()) {
final user = UserModel.fromMap(form.values);
api.saveUser(user);
}
// Load: API → model → form
final user = await repo.fetchUser();
form.patchValue(user.toMap());
State getters #
form.isValid // true if all controls are valid (after validate)
form.isDirty // true if any control has been edited
form.isTouched // true if any control has been focused
Dynamic controls #
Add or remove controls at runtime for stepwise / wizard / dynamic forms:
// Add a new field
form.addControl('phone', FormControl<String>(null, validators: [requiredValidator]));
// Add into a nested group
form.addControl('info.middleName', FormControl<String>(null));
// Remove a field
form.removeControl('phone');
// Check existence
if (form.containsControl('phone')) { ... }
Disabled controls #
Disable a control to exclude it from validation and FormGroup.values — useful for conditional fields like "shipping address same as billing":
// Disable — skips validate(), excluded from form.values, always valid
control.markAsDisabled();
// Re-enable
control.markAsEnabled();
// Check state
if (control.disabled) { /* grey out the UI */ }
Works on FormControl, FormArrayControl, and FormGroupArray. You can also construct a control as disabled:
FormControl<String>(null, enabled: false);
Array operations #
arrayControl.add('value'); // append a new child
arrayControl.insert(0, 'first'); // insert at index (clamps out-of-range)
arrayControl.addControl(myCtrl); // append a pre-built FormControl (keeps its validators)
arrayControl.move(from, to); // reorder for drag-and-drop
arrayControl.remove(index); // remove at index (no-op if out of range)
arrayControl.removeAll(); // drop every child
arrayControl.clear(); // keep children, null every value
arrayControl.reset(); // restore initial shape from constructor
arrayControl.setValue([...]); // resize + update, marks dirty
arrayControl.patchValue([...]); // resize + update, no dirty
addControl is useful when a specific item needs custom validators:
arrayControl.addControl(
FormControl<String>('special', validators: [minLength(5)]),
);
Form group arrays #
Use FormGroupArray for arrays of structured objects — addresses, line items, work history entries, etc. Each child is a full FormGroup with its own fields and validators:
final form = FormGroup({
'addresses': FormGroupArray(
[
FormGroup({
'street': FormControl<String>('123 Main', validators: [requiredValidator]),
'city': FormControl<String>('NYC', validators: [requiredValidator]),
'zip': FormControl<String>('10001'),
}),
],
// Factory for creating new empty groups via addGroup()
templateFactory: () => FormGroup({
'street': FormControl<String>(null, validators: [requiredValidator]),
'city': FormControl<String>(null, validators: [requiredValidator]),
'zip': FormControl<String>(null),
}),
),
});
final addresses = form.groupArrayControl('addresses');
addresses.addGroup(); // uses templateFactory
addresses.addGroup(myGroup); // explicit group
addresses.removeGroup(index); // remove at index
addresses.removeAll(); // drop all groups
addresses.values; // List<Map<String, dynamic>>
Use EzyFormGroupArrayControl in the widget tree. Wrap each child group in EzyFormWidget so that EzyFormControl resolves against the child group's context:
EzyFormGroupArrayControl(
formControlName: 'addresses',
builder: (context, groupArray) => Column(
children: [
for (var i = 0; i < groupArray.length; i++)
EzyFormWidget(
formGroup: groupArray.controls[i],
builder: (context, _) => Row(children: [
Expanded(
child: EzyFormControl<String>(
formControlName: 'street',
builder: (context, control, controller, focusNode) =>
TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
labelText: 'Street',
errorText: control.valid ? null : control.error,
),
),
),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => groupArray.removeGroup(i),
),
]),
),
TextButton(
onPressed: () => groupArray.addGroup(),
child: const Text('Add address'),
),
],
),
)
Array-level validators work the same way:
FormGroupArray(
[...],
arrayValidators: [
(groups) => (groups == null || groups.length < 2)
? 'Add at least 2 addresses'
: null,
],
)
Architecture #
FormControl<T>— single typed value, validators, dirty/touched/error/enabled state.FormArrayControl<T>— list ofFormControl<T>. Per-item validators propagated to children. Array-levelarrayValidatorsrun against the aggregated list. Supportsinsert,move,addControlfor reordering and custom-validator items.FormGroupArray— list ofFormGroups for arrays of structured objects (addresses, line items, etc.). SupportsaddGroup()/removeGroup()with optionaltemplateFactoryfor creating new groups.valuesreturnsList<Map<String, dynamic>>.FormGroup—Map<String, FormNode>of controls. Root aggregator forisValid,isDirty,isTouched,validate(),reset(),clear(). Supports dynamicaddControl()/removeControl(). Disabled controls are excluded fromvaluesand skipped byvalidate().
All four are ChangeNotifier subclasses. The widget layer (EzyFormWidget, EzyFormControl, EzyFormArrayControl, EzyFormGroupArrayControl, EzyFormConsumer) uses InheritedNotifier so only the subtree that depends on a specific notifier rebuilds.
