Skip to content

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.

signup_form.dart — running in Flutter idle

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.

signup_form.dart idle
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),
        ),
      ),
    );
  }
}
Field widgets prefixed Docs are shorthands the documentation defines, not part of the package. Rendering fields shows the widget code an app writes.
  • One call to registerFields and the form owns the fields: it disposes them, tracks wasModified, and reaches them in validate, resetAll and every other broadcast.
  • The field owns its TextEditingController and FocusNode. Bind the widget to field.textController and programmatic writes — reset, prefill, a relation — show up on screen.
  • Errors are your type. E is 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.

  1. The mode decides what triggers a field.

    Set ValidationMode once on the form and it reaches every field and subform. A field or a subform can opt out with a mode of its own.

  2. 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.

  3. 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

    manual

    Validates 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

    onUserInteraction

    Validates 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

    onUnfocus

    Validates 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 →

validation_modes.dart idle
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')),
          ],
        ),
      ],
    );
  }
}
Field widgets prefixed Docs are shorthands the documentation defines, not part of the package. Rendering fields shows the widget code an app writes.

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.

username_form.dart idle
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),
          ],
        ),
      ],
    );
  }
}
Field widgets prefixed Docs are shorthands the documentation defines, not part of the package. Rendering fields shows the widget code an app writes.
  • 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.

How rounds, verdicts and failures fit together →

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, mustBeTrue and the numeric checks, combined with & and | — or any E? 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

    subscribeToFields re-runs a rule when its dependencies change; addRelation derives 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, validating and validationErrors, 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 with setError, 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.