Your first form
A controller holds the fields and registers them; a widget binds to each field. Two rules prevent most bugs.
A form is two classes. A controller declares the fields, registers them, and decides what "submit" means. A widget binds one field to one input and asks the controller to submit. The form below is the code on this page, compiled and running — submit it empty, then fill it in.
Source3 files
class SignupFormController extends AdvancedFormController {
SignupFormController() {
registerFields([firstName, lastName]);
}
final firstName = AdvancedTextFieldController(
validator: filled('First name is required'),
);
final lastName = AdvancedTextFieldController(
validator: filled('Last name is required'),
);
Future<bool> submit() async {
if (await validate()) {
// The values were checked — send them.
return true;
}
return false;
}
}ExampleLog is a docs-only helper
ExampleLog.of(context).add(...) prints to the Output panel under the running form. In an app that line would be
a debugPrint, a snackbar, or a navigation. Everything else in the snippet is code you can paste as it is.
Reading the example
The controller extends AdvancedFormController. Its fields are plain final members, each with a validator: a
function from the value to an error, where null means valid. filled is a built-in;
the error type is whatever you pass it, a String here. The constructor calls registerFields once with every field.
submit awaits validate(). It runs every field's validators, the async ones included, and returns true only if
the whole form ended up valid. The await is not optional — it is the one thing that says the values were checked.
The widget owns the controller and disposes it. That one dispose() disposes the registered fields too, so a field
is never disposed by hand.
The field widget wraps a TextFormField in AdvancedFieldBuilder, which rebuilds it whenever the field notifies.
No TextEditingController allocation, no onChanged, no initialValue: the field owns its textController and its
focusNode, and the widget binds to both.
Two rules to remember
Call registerFields() once, with every field
The form then owns their lifecycle — it disposes them, tracks whether anything was modified, and includes them in
validate(), resetAll() and every other form-wide operation. A second call replaces the list. The parameter is a
List<AdvancedFieldController<dynamic, dynamic>>, so fields of different value types go in one list.
Bind widgets to field.textController, never to a controller of your own
The field keeps its TextEditingController in sync with its state in both directions: user input flows into the
field, and programmatic changes — setValue, reset, prefill, a relation — flow back into the visible text. A
controller you allocate yourself sees none of that.
What happens when you type
In the default mode, nothing — until the first validate(). Then an edit clears the error that described the old
value. For live feedback from the first keystroke, or a check when a field loses focus, set a
validation mode on the form.