Kodokon kodokon.com

Forms: TextField, controllers and validation

Drive your input fields with TextEditingController and validate every entry at once thanks to Form and TextFormField.

9 min · 3 questions

Open this lesson in Kodokon

An input field without a controller is a black box. TextEditingController puts you in control: read controller.text at any moment, pre-fill the field, clear it after submission, or listen to every keystroke with addListener. The controller is created in a State, never in build - otherwise it would be recreated on every rebuild.

DART
import 'package:flutter/material.dart';

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

  @override
  State<NicknameField> createState() =>
      _NicknameFieldState();
}

class _NicknameFieldState extends State<NicknameField> {
  final _controller = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: const InputDecoration(
        labelText: 'Nickname',
        border: OutlineInputBorder(),
      ),
      onSubmitted: (value) => debugPrint(value),
    );
  }
}
The controller lives in the State and dies in dispose().

As soon as a screen has several fields to check, adopt the trio Form, GlobalKey<FormState> and TextFormField. Each field describes its rule in validator: the function returns an error message, or null if all is well. A call to validate() on the key triggers all the validators at once.

DART
import 'package:flutter/material.dart';

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();
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      debugPrint('Email: ${_emailController.text}');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextFormField(
            controller: _emailController,
            keyboardType: TextInputType.emailAddress,
            decoration: const InputDecoration(
              labelText: 'Email',
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Required field';
              }
              if (!value.contains('@')) {
                return 'Invalid address';
              }
              return null;
            },
          ),
          const SizedBox(height: 16),
          FilledButton(
            onPressed: _submit,
            child: const Text('Create account'),
          ),
        ],
      ),
    );
  }
}
validate() triggers every validator in the form.

When the button is clicked, validate() runs each validator: the returned messages appear in red under the relevant fields, and the method returns false if at least one field is invalid. Your submission logic therefore stays behind a single if - it's impossible to submit invalid data.

Knowledge check

Make sure you remember the key points of this lesson.

  1. What is a TextEditingController for?
    • To draw the field's border
    • To read and modify the field's text, and listen to its changes
    • To automatically validate email addresses
    • To close the keyboard
  2. What should a validator return when the value is valid?
    • true
    • An empty string
    • null
    • The value itself
  3. Why call _controller.dispose() in the State's dispose() method?
    • To clear the field on screen
    • To release resources and avoid memory leaks
    • To submit the form