devShakib

Forms at Scale in Flutter Without a Form Package

Build large, scalable Flutter forms without a form package. Master Form, FormField, validators, async validation, focus traversal and submission state in pure Dart.

Every Flutter project eventually grows a form that is too big for a setState and a GlobalKey<FormState>, and the reflex is to reach for a form package that promises to handle everything. I've shipped checkout flows, onboarding wizards, and multi-section merchant-settings screens at Shpper, and the pattern that has survived every one of them uses nothing but Form, FormField, and about forty lines of controller code. The framework already gives you more than people realise — the trick is knowing which parts to lean on and which parts to build yourself.

This post is the whole pattern: how I structure a large Flutter form, how I do validation (including async validation against a backend), how I wire keyboard focus traversal, and how I model submission state so a submit button can never fire twice. It's opinionated, but every piece has survived real production traffic.

Why not reach for a Flutter form package

Form packages solve real problems, but they solve them by owning your entire form state. That's a fine trade for a five-field settings screen. It stops being fine when you have conditional fields, async validation against your backend, cross-field rules ("shipping equals billing" toggles), and a submit button whose enabled/disabled/loading state depends on all of the above. At that point you're fighting the package's abstractions instead of the problem, and every custom widget needs an adapter to plug into its field registry.

There's a second, quieter cost: coupling. A form package becomes load-bearing infrastructure that every screen imports. When it lags a Flutter release, or a maintainer moves on, or the API changes shape in a major version, you inherit a migration you didn't choose. The Form widget, by contrast, ships with the SDK and moves at the SDK's pace. For code that lives at the center of your app — checkout, signup, KYC — I want the fewest moving dependencies I can get away with.

The framework's own Form/FormField machinery is deliberately unopinionated: it handles registration, validation orchestration, save, and reset, and stays out of everything else. Every FormField descendant (including TextFormField, DropdownButtonFormField, and a CheckboxListTile wrapped in a FormField) registers itself with the nearest Form ancestor. When you call validate(), save(), or reset() on the FormState, it fans the call out to every registered field for you. Once you accept that you own submission state and field values yourself, the framework stops being a constraint and becomes exactly the right amount of infrastructure.

Separate the three concerns of a large form

I split every large form into three concerns that people usually tangle together:

Keeping these apart is what makes the form scale. Validation logic doesn't know about focus. The submit button doesn't reach into individual fields. Each concern has one home, which means each can change independently — the whole reason the pattern survives requirement churn.

A quick mental model: the Form is a coordinator, not a store. It doesn't hold your values; it holds references to fields and knows how to ask them things. Your model holds values. Your controller holds process state. Draw that boundary once and the rest falls out naturally.

Validation in Flutter: trust the validator, control the timing

The mistake I see most often is validating on every keystroke from the first character, so a user sees "email is invalid" while they're still typing the @. The Form widget has an autovalidateMode for exactly this, and the value that scales is AutovalidateMode.onUserInteraction. It stays quiet until the field has been touched, then re-validates live — which is the error UX users actually expect. You can set it on the whole Form or per FormField; I usually set it per field so a freshly-focused field starts clean even inside an already-touched form.

Compose validators instead of writing one mega-function per field. A tiny combinator keeps them readable and reusable across the whole app:

typedef Validator<T> = String? Function(T? value);Validator<T> combine<T>(List<Validator<T>> validators) {  return (value) {    for (final validator in validators) {      final error = validator(value);      if (error != null) return error;    }    return null;  };}String? required(String? v) =>    (v == null || v.trim().isEmpty) ? 'Required' : null;Validator<String> minLength(int n) =>    (v) => (v ?? '').length < n ? 'At least $n characters' : null;Validator<String> matches(RegExp pattern, String message) =>    (v) => (v == null || !pattern.hasMatch(v)) ? message : null;// usageTextFormField(  validator: combine([required, minLength(8)]),  autovalidateMode: AutovalidateMode.onUserInteraction,)

The combinator returns the first error, which is what you want — showing a stack of three simultaneous errors under one field is noise. Order the validators from cheapest and most fundamental (required) to most specific (matches) so the message the user sees is the most actionable one.

Cross-field validation without a package

Rules like "confirm password must equal password" or "end date must be after start date" trip people up because a validator only sees its own value. The fix is simple: give the validator a closure over the other field's current value. Since validate() re-runs every field, editing either side re-checks the relationship:

TextFormField(  controller: _confirmController,  validator: (v) =>      v != _passwordController.text ? 'Passwords do not match' : null,  autovalidateMode: AutovalidateMode.onUserInteraction,)

No shared form state, no field registry — just a closure. This is the moment where owning your own controllers pays off, because the confirm field can read the password field's value directly.

Async validation against a backend

For async validation — checking a coupon code or a unique username against the backend — don't try to force it through the synchronous validator. A FormField validator must return String? synchronously; it cannot await a network call, and trying to bolt a Future onto it is where most home-grown form code goes wrong. Instead, run the check in your controller, store the result as a server-side error string, and feed it back into the field.

The flow is: debounce the input, fire the request in the controller, stash the outcome ('That username is taken' or null), then have the field's validator return that stored value, and call formKey.currentState!.validate() after the async call resolves to surface it in place.

// In the controllerString? usernameError;Future<void> checkUsername(String value) async {  final taken = await _api.isUsernameTaken(value);  usernameError = taken ? 'That username is taken' : null;  notifyListeners();}// In the fieldTextFormField(  validator: (v) => required(v) ?? _controller.usernameError,  onChanged: (v) => _debouncer.run(() => _controller.checkUsername(v)),)

This keeps the async round-trip out of the render path where it doesn't belong, and it keeps the field's validator synchronous and pure. The network layer decides the truth; the field just reports it.

Focus traversal: keyboard flow is a feature, not a nicety

On a long form, tapping "next" on the keyboard should move to the next field, and the last field's action should submit. This is pure boilerplate that pays for itself immediately. Hold one FocusNode per field and chain them:

TextFormField(  focusNode: _emailNode,  textInputAction: TextInputAction.next,  onFieldSubmitted: (_) => _passwordNode.requestFocus(),),TextFormField(  focusNode: _passwordNode,  textInputAction: TextInputAction.done,  onFieldSubmitted: (_) => _submit(),),

Two things I've learned to do here every time. First, dispose your focus nodes in dispose() — and your TextEditingControllers too. It's the leak nobody notices until a profiling session, and on a form with a dozen fields it adds up fast:

@overridevoid dispose() {  _emailNode.dispose();  _passwordNode.dispose();  _passwordController.dispose();  _confirmController.dispose();  super.dispose();}

Second, on validation failure, don't just paint red text; move focus to the first invalid field. On a form that scrolls past the fold, an error the user can't see is a dead end — they tap submit, nothing visibly happens, and they assume the button is broken. Track the field order, find the first one whose validator fails, then call requestFocus() and Scrollable.ensureVisible() on that field's context to bring it on screen:

void _focusFirstInvalidField() {  for (final field in _orderedFields) {    if (field.validator(field.controller.text) != null) {      field.node.requestFocus();      Scrollable.ensureVisible(        field.node.context!,        alignment: 0.1,        duration: const Duration(milliseconds: 250),      );      return;    }  }}

That single behaviour is the difference between a form that feels broken and one that feels helpful. It costs a few lines and it's the first thing QA notices.

Submission state: one flag, one source of truth

The submit button's job is to reflect exactly one thing: the controller's state. I model submission as a small enum or set of booleans and let the UI be a pure function of it.

class SignupController extends ChangeNotifier {  bool isSubmitting = false;  String? serverError;  Future<bool> submit(SignupModel model) async {    isSubmitting = true;    serverError = null;    notifyListeners();    try {      await _api.signup(model);      return true;    } on ApiException catch (e) {      serverError = e.message;      return false;    } finally {      isSubmitting = false;      notifyListeners();    }  }}

The widget side becomes trivial and, crucially, guards against double submission — a real bug I've watched create duplicate orders in production because a button fired twice before the first request returned:

Future<void> _submit() async {  if (_controller.isSubmitting) return;  if (!_formKey.currentState!.validate()) {    _focusFirstInvalidField();    return;  }  _formKey.currentState!.save();          // pushes values into the model  final ok = await _controller.submit(_model);  if (ok && mounted) Navigator.pop(context);}

Note save(): I let each TextFormField's onSaved write into a plain Dart model, so the controller never has to know about individual TextEditingControllers. The form collects its own values on demand, the model stays typed and testable, and the controller only sees the finished object.

TextFormField(  onSaved: (v) => _model.email = v!.trim(),)

The mounted check after the await matters more than it looks: any time you await inside a State method and then touch context, the widget may have been disposed while the request was in flight. Guarding with mounted is the difference between a clean navigation and a crash report.

Testing this pattern

Because values live in a plain Dart model and process state lives in a ChangeNotifier, the interesting logic is testable without pumping a single widget. Validators are pure functions — feed them strings, assert the messages. The controller is a plain object — mock the API, call submit, assert the serverError and isSubmitting transitions. You only need WidgetTester for the thin wiring layer, and even that is straightforward:

testWidgets('shows error and focuses first invalid field', (tester) async {  await tester.pumpWidget(const SignupScreen());  await tester.tap(find.byKey(const Key('submit')));  await tester.pump();  expect(find.text('Required'), findsWidgets);});

That testability is a direct consequence of the three-way split. A form package that owns your state usually forces you into widget tests for logic that should have been a unit test.

Key takeaways

You don't need a form package to build serious forms in Flutter — you need to separate three concerns and let the framework do the one job it's good at. Reach for the package when the form is small and static. When it's large and evolving, this pattern will outlast it.