v0.2.2Apache-2.0Easy to useTime-savingOut of the box
Complicated forms. Simple code.
Typed fields, three validation modes out of the box, async checks with cancellation, cross-field rules and wizards for Flutter — wired the same way a two-field sign-up is, on the
ChangeNotifier and ValueListenable your app already uses.
flutter pub add advanced_forms
Coming from leancode_forms 0.1.x? Read the migration guide.
Try it — a live demo running in Flutter. Nothing you type is sent anywhere.
Agent skill
Your coding agent already knows this API.
The package ships an Agent Skill that teaches Claude Code — or any agent that supports skills — the full
advanced_forms API, so it generates fields, validation, cross-field logic and subforms idiomatically. It ships in the package: one command puts it into your agent's skills folder, then say "build me a sign-up form".
dart run skills@ get
The model
A controller holds the fields. A widget binds to each one.
No Form widget, no GlobalKey, no TextEditingController plumbing. The controller below is the whole form; the widgets only render it. It is running on the right — submit it empty.
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;
}
}
class SignupForm extends StatefulWidget {
const SignupForm({super.key});
@override
State<SignupForm> createState() => _SignupFormState();
}
class _SignupFormState extends State<SignupForm> {
final _form = SignupFormController();
@override
void dispose() {
_form.dispose(); // disposes the registered fields too
super.dispose();
}
Future<void> _submit() async {
final sent = await _form.submit();
if (mounted) {
ExampleLog.of(context).add(
sent
? 'Welcome, ${_form.firstName.fieldValue}!'
: 'Fix the errors above.',
);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SignupTextField(field: _form.firstName, label: 'First name'),
_SignupTextField(field: _form.lastName, label: 'Last name'),
const SizedBox(height: 12),
FilledButton(onPressed: _submit, child: const Text('Submit')),
],
);
}
}
class _SignupTextField extends StatelessWidget {
const _SignupTextField({required this.field, required this.label});
final AdvancedTextFieldController<String> field; // <String> is the error type
final String label;
@override
Widget build(BuildContext context) {
return AdvancedFieldBuilder<String, String>(
field: field,
builder: (context, state, _) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
controller: field.textController,
focusNode: field.focusNode,
decoration: InputDecoration(labelText: label, errorText: state.error),
),
),
);
}
}
-
One call to
registerFieldsand the form owns the fields: it disposes them, trackswasModified, and reaches them invalidate,resetAlland every other broadcast. -
The field owns its
TextEditingControllerandFocusNode. Bind the widget tofield.textControllerand programmatic writes —reset,prefill, a relation — show up on screen. -
Errors are your type.
Eis whatever you choose: a string, an enum, a sealed class. The package never formats a message.
Three rules
Validation you can predict.
The whole trigger behaviour is twenty lines of Dart. Pick a mode, then switch it below while you type to feel the difference.
-
The mode decides what triggers a field.
Set
ValidationModeonce on the form and it reaches every field and subform. A field or a subform can opt out with a mode of its own. -
Sync first, async only if sync passed.
A round never asks the server about a value the sync validator already rejected. One round per field at a time; a newer value replaces the round in flight.
-
Untouched fields stay quiet.
A field the user has never edited validates nothing on its own, in every mode.
validate()is what checks those — so a prefilled form never greets the user with errors.
-
default
manualValidates on submit.
Nothing shouts while the user fills the form in. After the first submit, an edit clears the error that described the old value.
-
live
onUserInteractionValidates on every keystroke.
Immediate feedback on the field being edited. Async checks wait out their debounce, so typing runs one request, not ten.
-
on leave
onUnfocusValidates when a field loses focus.
The user finishes a field, moves on, and sees the verdict. Tabbing through a field they never touched costs nothing.
Every mode and every event, explained →
class ProfileFormController extends AdvancedFormController {
ProfileFormController() {
registerFields([username, website]);
}
final username = AdvancedTextFieldController(
validator:
filled('Username is required') &
atLeastLength(3, 'At least 3 characters'),
);
final website = AdvancedTextFieldController(
validator: (value) => value.isEmpty || value.startsWith('https://')
? null
: 'Must start with https://',
);
}
class ValidationModesDemo extends StatefulWidget {
const ValidationModesDemo({super.key});
@override
State<ValidationModesDemo> createState() => _ValidationModesDemoState();
}
class _ValidationModesDemoState extends State<ValidationModesDemo> {
final _form = ProfileFormController();
var _mode = ValidationMode.manual;
@override
void dispose() {
_form.dispose();
super.dispose();
}
void _setMode(ValidationMode mode) {
setState(() => _mode = mode);
// One call reaches every field and subform in the tree.
_form.setValidationMode(mode);
}
Future<void> _submit() async {
final ok = await _form.validate();
if (mounted) {
ExampleLog.of(context).add(ok ? 'Saved.' : 'Not saved: invalid.');
}
}
@override
Widget build(BuildContext context) {
final hint = switch (_mode) {
ValidationMode.manual =>
'Nothing validates until you press Save. An edit still clears '
'the error that described the old value.',
ValidationMode.onUserInteraction =>
'Every keystroke validates the field being edited.',
ValidationMode.onUnfocus =>
'Leaving a field you edited validates it. Tabbing through an '
'untouched field costs nothing.',
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<ValidationMode>(
showSelectedIcon: false,
segments: [
for (final mode in ValidationMode.values)
ButtonSegment(value: mode, label: Text(mode.name)),
],
selected: {_mode},
onSelectionChanged: (selection) => _setMode(selection.first),
),
const SizedBox(height: 8),
DocsHint(hint),
DocsTextField(field: _form.username, label: 'Username'),
DocsTextField(
field: _form.website,
label: 'Website',
hint: 'https://…',
),
DocsActions(
children: [
DocsSubmitButton(form: _form, label: 'Save', onPressed: _submit),
TextButton(onPressed: _form.resetAll, child: const Text('Reset')),
],
),
],
);
}
}
Server-side checks
Async validation without the races.
“Is this username taken?” is one AsyncValidation. Debounce, cancellation, timeout and failure handling come with it — try
alice, then boom.
enum UsernameError { required, tooShort, taken, checkFailed }
class UsernameFormController extends AdvancedFormController {
UsernameFormController()
: super(validationMode: ValidationMode.onUserInteraction) {
registerFields([username]);
}
late final username = AdvancedTextFieldController(
validator:
filled(UsernameError.required) &
atLeastLength(3, UsernameError.tooShort),
asyncValidation: AsyncValidation(
validator: _isAvailable, // Future<UsernameError?> Function(String)
debounce: const Duration(milliseconds: 400), // default 300 ms
timeout: const Duration(seconds: 3), // default: no bound
failureToError: (error, stackTrace) => UsernameError.checkFailed,
),
);
Future<UsernameError?> _isAvailable(String value) async {
await Future<void>.delayed(const Duration(milliseconds: 700));
if (value == 'boom') {
throw Exception('the directory service is down');
}
const taken = {'alice', 'bob', 'admin'};
return taken.contains(value) ? UsernameError.taken : null;
}
}
class UsernameAvailabilityForm extends StatefulWidget {
const UsernameAvailabilityForm({super.key});
@override
State<UsernameAvailabilityForm> createState() =>
_UsernameAvailabilityFormState();
}
class _UsernameAvailabilityFormState extends State<UsernameAvailabilityForm> {
final _form = UsernameFormController();
@override
void dispose() {
_form.dispose();
super.dispose();
}
Future<void> _submit() async {
// Awaits the check in flight instead of failing a busy field.
final ok = await _form.validate();
if (mounted) {
final name = _form.username.fieldValue;
ExampleLog.of(context).add(ok ? '@$name is yours.' : 'Not submitted.');
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DocsHint(
'alice, bob and admin are taken. boom makes the check throw.',
),
DocsFailureBanner(form: _form),
DocsTextField(
field: _form.username,
label: 'Username',
icon: Icons.person_outline,
translateError: (error) => switch (error) {
UsernameError.required => 'Pick a username',
UsernameError.tooShort => 'At least 3 characters',
UsernameError.taken => 'Already taken — try another',
UsernameError.checkFailed => 'Could not check availability',
},
),
Row(
children: [
DocsFieldStatus(field: _form.username, label: 'status'),
const Spacer(),
DocsSubmitButton(form: _form, label: 'Claim', onPressed: _submit),
],
),
],
);
}
}
-
Debounced while typing, immediate on submit.
await validate()flushes a waiting check rather than reporting the field bad for being busy. - A stale answer can never land. A new value replaces the round in flight; the old result is dropped, not applied late.
- Verdicts are reused. A second submit on an unchanged form makes zero network calls.
-
A failure is not an error. A validator that throws or times out puts the field on
failedValidation: not valid, not stuck, retried by the next submit.
Everything a form needs
Small enough to read. Complete enough to ship.
About two thousand lines of Dart, three runtime dependencies, and no build step. Every behaviour below is pinned by the package’s test suite and shown running in the docs.
-
Typed field controllers
Text, boolean, single-select and multi-select fields, each with its own value type and its own error type. Plain strings to start, an enum or a sealed class when the form grows.
-
Validators that compose
filled,atLeastLength,notNull,mustBeTrueand the numeric checks, combined with&and|— or anyE? Function(T)you write yourself. -
Async validation done right
Debounced server checks with a timeout, cancellation of stale rounds, a cached verdict while the value stands, and a failure model that never leaves a field stuck on “validating”.
-
Three validation modes
Validate on submit, on every keystroke, or when a field loses focus. Set once on the form; a field or a subform can opt out with its own mode.
-
Cross-field logic
subscribeToFieldsre-runs a rule when its dependencies change;addRelationderives one field’s value from another. Repeat-password and running totals in two lines each. -
Subforms
Attach and detach nested controllers. Their fields join the parent’s
validate, reset, read-only and error handling — one subform per wizard step, or one per row of a dynamic list. -
Form-level state
canSubmit,wasModified,validatingandvalidationErrors, derived from the live tree on every read and ready to bind to a submit button. -
Read-only fields and server errors
Freeze a value with
markReadOnly, push a 422 response in withsetError, and let the next edit clear it — no bookkeeping. -
Granular rebuilds
One builder per field. A keystroke rebuilds that field’s subtree and nothing else, and a no-op write notifies nobody because state is value-equal.