Forms & Input Handling

Almost every app eventually needs to collect something from the user — a search query, a login, a comment. Flutter's input widgets are, like everything else, just widgets, and they compose with the same layout tools you already know. This lesson covers the basic TextField, how to read and control its value with a TextEditingController, and the Form system for validating several fields together before submission.

TextField: The Basic Text Input

TextField is the fundamental widget for free-form text entry. On its own it just displays a text box and updates itself as the user types — for most real use cases you'll pair it with a controller (below) so your own code can read what was typed.

TextField(
  decoration: InputDecoration(
    labelText: 'Your name',
    hintText: 'e.g. Jane Doe',
    border: OutlineInputBorder(),
  ),
)

TextEditingController: Reading and Controlling the Value

A TextEditingController attaches to a TextField and gives your code direct access to its text — you can read controller.text at any time, set it programmatically, and even listen for every change. Because it's a long-lived object tied to the widget's state, it must be created in initState and, critically, disposed in dispose — skipping this leaks memory every time the widget is rebuilt or removed from the tree.

class NameField extends StatefulWidget {
  const NameField({super.key});

  @override
  State<NameField> createState() => _NameFieldState();
}

class _NameFieldState extends State<NameField> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose(); // always dispose controllers to avoid memory leaks
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _controller,
          decoration: const InputDecoration(labelText: 'Name'),
        ),
        ElevatedButton(
          onPressed: () {
            // Read the current value at any time
            print('Name entered: ${_controller.text}');
          },
          child: const Text('Print Name'),
        ),
      ],
    );
  }
}
Note: Forgetting dispose() is one of the most common Flutter memory leak bugs — the controller (and its internal listeners) stays alive in memory long after the widget that created it is gone, if nothing ever cleans it up.

The Form Widget and GlobalKey<FormState>

When you have several fields that need to be validated together — say, before enabling a "Submit" button — wrap them in a Form widget. A Form doesn't render anything visually by itself; it coordinates the validation state of every TextFormField beneath it. You reach into that coordinated state using a GlobalKey<FormState>, which you create once and attach to the Form's key property.

final _formKey = GlobalKey<FormState>();

Form(
  key: _formKey,
  child: Column(
    children: [
      // TextFormField widgets go here
    ],
  ),
)

TextFormField and Validators

TextFormField is the Form-aware version of TextField — it accepts a validator function that receives the field's current text and returns an error message String if it's invalid, or null if it's fine. Calling _formKey.currentState!.validate() runs every field's validator at once and returns true only if all of them passed — Flutter automatically displays any returned error messages under the relevant fields.

TextFormField(
  decoration: const InputDecoration(labelText: 'Email'),
  validator: (value) {
    if (value == null || value.isEmpty) {
      return 'Email is required';
    }
    if (!value.contains('@')) {
      return 'Enter a valid email';
    }
    return null; // null means "valid"
  },
)

// Triggering validation, typically from a submit button
ElevatedButton(
  onPressed: () {
    if (_formKey.currentState!.validate()) {
      // all fields passed their validators
      print('Form is valid, submitting...');
    }
  },
  child: const Text('Submit'),
)

onChanged and onSubmitted

Both TextField and TextFormField expose two useful callbacks: onChanged fires on every keystroke, useful for live search-as-you-type or a live character counter, while onSubmitted fires once, when the user presses the keyboard's "done"/"go" action.

TextField(
  onChanged: (value) {
    print('User is typing: $value');
  },
  onSubmitted: (value) {
    print('User submitted: $value');
  },
)

Other Common Input Widgets

Text isn't the only kind of input. Flutter ships several dedicated widgets for the other common patterns you'll need in almost any form.

WidgetUsed for
CheckboxA single on/off box, typically paired with a Text label ("I agree to the terms")
SwitchAn on/off toggle styled like a physical switch — commonly used for settings screens
RadioOne choice from a small fixed set, where only one option can be selected at a time (grouped by a shared groupValue)
DropdownButtonA tappable field that expands into a list of choices, good for longer option lists
// A Switch example
Switch(
  value: _notificationsEnabled,
  onChanged: (bool value) {
    setState(() { _notificationsEnabled = value; });
  },
)

// A DropdownButton example
DropdownButton<String>(
  value: _selectedCountry,
  items: ['India', 'USA', 'UK']
      .map((c) => DropdownMenuItem(value: c, child: Text(c)))
      .toList(),
  onChanged: (value) {
    setState(() { _selectedCountry = value; });
  },
)

Full Example: A Simple Validated Form

Putting it all together — a name field, an email field with validation, and a submit button that only proceeds once everything passes.

class SignupForm extends StatefulWidget {
  const SignupForm({super.key});

  @override
  State<SignupForm> createState() => _SignupFormState();
}

class _SignupFormState extends State<SignupForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Name'),
            validator: (value) =>
                (value == null || value.isEmpty) ? 'Name is required' : null,
          ),
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(labelText: 'Email'),
            validator: (value) {
              if (value == null || !value.contains('@')) {
                return 'Enter a valid email';
              }
              return null;
            },
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('Signed up as ${_emailController.text}')),
                );
              }
            },
            child: const Text('Sign Up'),
          ),
        ],
      ),
    );
  }
}
Next up: Now that you can collect input, let's look at how to display collections of data efficiently with Lists & ListView.