Skip to content
Back to skills

Forms And Input

ASecurity

Enforces Form + GlobalKey<FormState> with TextFormField whose sync validator returns a localized String? (never a hardcoded literal), AutovalidateMode.onUserInteraction, async availability checks moved OUT of the sync validator into a debounced Riverpod Notifier that surfaces errors through state, FocusNode/TextInputAction/onFieldSubmitted traversal, keyboardType/textCapitalization/autofillHints/TextInputFormatter, mandatory TextEditingController/FocusNode disposal, submit-enabled derived fro...

  • 15 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 19, 2026
ai-agentsgonodeapiperformance

Works with

  • cursor
  • api

Security analysis

A100/100

Pro scans all 6 files and shows the line behind each finding

Scanned September 19, 2026

npx -y skills add zakariaf/Flutter-Skills --skill forms-and-input --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Forms And Input?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Forms And Input
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-forms-and-input/badge)](https://www.skillsdirectory.com/skills/zakariaf-forms-and-input)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: forms-and-input
description: Enforces Form + GlobalKey<FormState> with TextFormField whose sync validator returns a localized String? (never a hardcoded literal), AutovalidateMode.onUserInteraction, async availability checks moved OUT of the sync validator into a debounced Riverpod Notifier that surfaces errors through state, FocusNode/TextInputAction/onFieldSubmitted traversal, keyboardType/textCapitalization/autofillHints/TextInputFormatter, mandatory TextEditingController/FocusNode disposal, submit-enabled derived from validity (not stored), and scoped rebuilds so a keystroke never rebuilds the whole form. Use when building a Form, TextFormField, or FormField; wiring sync or async validation; managing FocusNode, focus traversal, autofocus, TextInputAction, onFieldSubmitted, or onEditingComplete; setting keyboardType, autofillHints, textCapitalization, or InputFormatter; disposing TextEditingController/FocusNode; enabling/disabling a submit button; or handling keyboard-avoidance on submit.
---

# Forms and input

Text input is where most disposal leaks, un-localized strings, and jank enter a Flutter app. A form is a small state machine: fields hold text, a `FormState` validates them, and a ViewModel owns anything that touches the network or the clock. Keep those three responsibilities separate.

Read the reference for the task at hand:

- `references/validation-sync-and-async.md` — sync `validator` returning localized `String?`, `AutovalidateMode` choice, and the debounced-async-in-a-Notifier pattern (why async must NOT live in the sync validator).
- `references/focus-and-keyboard.md` — `FocusNode` lifecycle, traversal order, `autofocus`, `FocusTraversalGroup`, `TextInputAction`, `onFieldSubmitted`/`onEditingComplete`, `keyboardType`, `autofillHints`, `TextInputFormatter`, keyboard-avoidance.

Run `scripts/check_forms.sh` before a PR.

## Non-negotiable rules

1. **Every `TextEditingController` and `FocusNode` created in a `State` is disposed in `dispose()`.** They hold native resources and listeners; a leak survives the widget and fires callbacks against a dead tree. If the value must outlive the widget, hold it in a Notifier instead — see `state-management-riverpod`.
2. **Validator messages are localized, never hardcoded.** A `validator` returns `AppLocalizations.of(context).fieldRequired`, not `'Required'`. Error text is user-facing UI copy and is owned by `i18n-rtl-l10n`. The `check_forms.sh` grep fails on string literals returned from a validator.
3. **The sync `validator` is pure and instant — no `await`, no network, no `Future`.** `FormFieldValidator<T>` is `String? Function(T?)`; it cannot be async and Flutter calls it synchronously during layout. Availability/uniqueness checks belong in a Notifier (rule 4).
4. **Async validation lives in a debounced Riverpod Notifier and surfaces through state.** Debounce with a `dart:async` `Timer` (kept deterministic in tests via `fakeAsync`, not by the clock), run the check, and expose `AsyncValue`/a sealed status the field reads via `InputDecoration.errorText`. Any *timestamp* the check records comes from `ref.read(clockProvider).now()`, never `DateTime.now()` — the Clock seam is owned by `service-boundary-and-native`. Never block a keystroke on I/O. See `async-safety` for cancel-on-dispose.
5. **Submit-enabled is DERIVED from validity, never stored as a separate `bool`.** A stored `_isValid` flag drifts out of sync with the fields. Compute it from `FormState`/Notifier state at build time. See `flutter-performance` (derive-don't-store).
6. **A keystroke rebuilds one field, not the whole form.** Give each field its own controller/`FormField`; do not lift raw text into a top-level `setState`/`watch` that rebuilds every sibling. Scope rebuilds with small widgets and `ref.watch(....select(...))`. See `widget-composition` and `flutter-performance`.
7. **Choose `AutovalidateMode` deliberately.** Default to `AutovalidateMode.onUserInteraction`: silent until the user touches a field, then live. Never `always` (screams before the user types). Validate-on-submit only for short forms where per-field feedback is noise.
8. **Keyboard type, capitalization, and autofill are declared per field.** `keyboardType`, `textCapitalization`, `autofillHints`, and `TextInputFormatter`s are structural input contracts, not decoration. A missing `autofillHints` breaks OS autofill and password managers.
9. **Errors are announced, not just colored.** `InputDecoration.labelText`/`errorText` carry semantics that screen readers read on change; never signal an error with color alone. See `accessibility-as-code`.

## Form skeleton

`Form` + a `GlobalKey<FormState>` is the coordination point. The key lets the submit handler call `validate()`/`save()` across all fields at once.

```dart
class TaskForm extends StatefulWidget {
  const TaskForm({super.key, required this.onSubmit});
  final void Function(String title) onSubmit;

  @override
  State<TaskForm> createState() => _TaskFormState();
}

class _TaskFormState extends State<TaskForm> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _titleFocus = FocusNode();

  @override
  void dispose() {
    _titleController.dispose(); // rule 1: always dispose
    _titleFocus.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState?.validate() ?? false) {
      widget.onSubmit(_titleController.text.trim());
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context);
    return Form(
      key: _formKey,
      autovalidateMode: AutovalidateMode.onUserInteraction, // rule 7
      child: Column(
        children: [
          TextFormField(
            controller: _titleController,
            focusNode: _titleFocus,
            autofocus: true,
            textInputAction: TextInputAction.done,
            keyboardType: TextInputType.text,
            textCapitalization: TextCapitalization.sentences,
            decoration: InputDecoration(labelText: l10n.taskTitleLabel),
            validator: (value) => // rule 2 + 3: localized, pure
                (value == null || value.trim().isEmpty) ? l10n.fieldRequired : null,
            onFieldSubmitted: (_) => _submit(),
          ),
        ],
      ),
    );
  }
}
```

## Sync validation

The `validator` is a total, synchronous function of the field value. Return `null` for valid, a localized message otherwise. Compose small checks; keep the closure short.

```dart
String? validateTitle(String? value, AppLocalizations l10n) {
  final text = value?.trim() ?? '';
  if (text.isEmpty) return l10n.fieldRequired;
  if (text.length > 120) return l10n.fieldTooLong; // structural bound, not design
  return null;
}
```

## Async validation (out of the validator)

An availability check (is this account name taken?) is I/O. It runs in a Notifier, debounced against `clockProvider`, and the field reads the result through `errorText`. The sync `validator` stays pure and only guards the shape of the input. Full pattern in `references/validation-sync-and-async.md` and `examples/async_field_notifier.dart`.

```dart
// The field is driven by Notifier state, not by an async validator.
final status = ref.watch(nameAvailabilityNotifierProvider);
TextFormField(
  controller: _nameController,
  onChanged: ref.read(nameAvailabilityNotifierProvider.notifier).onNameChanged,
  decoration: InputDecoration(
    labelText: l10n.accountNameLabel,
    errorText: switch (status) {
      AsyncData(:final value) when value == NameCheck.taken => l10n.nameTaken,
      AsyncError() => l10n.nameCheckFailed,
      _ => null, // idle / loading / available: no error
    },
  ),
);
```

## Focus and keyboard flow

`TextInputAction.next` moves to the next field; `.done` submits. Advance focus in `onFieldSubmitted` with `FocusScope.of(context).nextFocus()` or by requesting a specific node. Group related fields with `FocusTraversalGroup` to control tab order. Details in `references/focus-and-keyboard.md`.

```dart
TextFormField(
  focusNode: _titleFocus,
  textInputAction: TextInputAction.next,
  onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),
```

## Submit button derived from validity

Do not store an `_isFormValid` bool. Derive enablement each build; disable while an async submit is in flight (from Notifier state).

```dart
final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
  onPressed: submitting ? null : _submit, // rule 5
  child: Text(l10n.saveAction),
);
```

## Keyboard avoidance

Wrap long forms so the focused field scrolls above the keyboard: a `SingleChildScrollView` inside the body lets `Scaffold` (with `resizeToAvoidBottomInset: true`, the default) push content up. For last-field submit, ensure the submit button is reachable — put it in the scroll view or a `bottomNavigationBar`.

## Anti-patterns

- `validator: (v) async => await repo.isTaken(v)` — a validator cannot be async; the `Future` is truthy so it always "passes." Move to a Notifier (rule 4).
- Returning `'Required'` / `'Invalid email'` from a validator — un-localized; breaks every non-English locale. Use `AppLocalizations`.
- Creating a `TextEditingController`/`FocusNode` in `build()` — a fresh one every rebuild, losing text and cursor. Create in `State`, dispose in `dispose()`.
- `bool _isValid` toggled in `onChanged` to enable submit — drifts from real validity. Derive it.
- `autovalidateMode: AutovalidateMode.always` — errors shout before the user types a character.
- One `TextEditingController` listener that calls `setState` on the whole form — every keystroke rebuilds every field. Scope the rebuild.
- `debounce` timing rolled by hand with `DateTime.now()` diffs — use a `dart:async` `Timer` (deterministic under `fakeAsync`); and any timestamp the check records comes from `ref.read(clockProvider).now()`, never `DateTime.now()`.

## Definition of done

- Every controller/`FocusNode` disposed (or state lives in a Notifier); `check_forms.sh` clean.
- No string literal returned from any `validator`; all messages via `AppLocalizations`.
- No `await`/`Future` inside a sync `validator`; async checks in a debounced Notifier surfaced through `errorText`.
- `AutovalidateMode.onUserInteraction` (or an intentional submit-only choice).
- Submit enablement derived, not stored; disabled during in-flight submit.
- Each field declares `keyboardType`, `textInputAction`, and `autofillHints` where applicable; focus advances correctly.
- Errors announced via `InputDecoration` semantics, never color-only.

## Related skills

- `state-management-riverpod` — the Notifier that owns async validation and submit state.
- `async-safety` — cancel debounce timers/subscriptions on dispose; mounted guards after await.
- `i18n-rtl-l10n` — localized validator messages and labels; the non-null `AppLocalizations.of(context)` getter.
- `accessibility-as-code` — field labels, error announcement, never-color-alone, target sizes.
- `flutter-performance` — scoped rebuilds and derive-don't-store.
- `widget-composition` — small const field widgets over `_buildField` methods.
- `navigation-and-routing` — `PopScope` for unsaved-changes confirmation when leaving a dirty form.
- `service-boundary-and-native` — the Clock seam (`clockProvider`) any async check reads timestamps from.

## References

- Form: https://api.flutter.dev/flutter/widgets/Form-class.html
- TextFormField: https://api.flutter.dev/flutter/material/TextFormField-class.html
- FocusNode: https://api.flutter.dev/flutter/widgets/FocusNode-class.html
- TextInputAction: https://api.flutter.dev/flutter/services/TextInputAction.html
- Autofill: https://api.flutter.dev/flutter/services/AutofillHints-class.html
- Forms cookbook: https://docs.flutter.dev/cookbook/forms/validation

Files in this skill

  • SKILL.md11.6 KB
  • examples/async_field_notifier.dart4.7 KB
  • examples/task_form.dart4.8 KB
  • references/focus-and-keyboard.md5.2 KB
  • references/validation-sync-and-async.md5.5 KB
  • scripts/check_forms.sh2.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.