HomebaseWorks
Lesson 3 of 4

Forms, TextEditingController and validation

Read what the user typed, validate every field with one call, and dispose controllers so the app does not leak memory.

EWritten by the HomebaseWorks editorial team · reviewed 26 Oct 2026

A login or sign-up form is the most common Flutter take-home task. It tests whether you know the difference between TextField and TextFormField, how to get the typed text, and whether you clean up after yourself. The pattern below covers all three.

Advertisement

The pieces

  • Form groups fields and owns a GlobalKey<FormState> so you can validate them all at once.
  • TextFormField is a TextField with validator and onSaved added. Plain TextField cannot validate through the form.
  • TextEditingController holds the current text and lets you read or set it from code.
class LoginForm extends StatefulWidget {
  const LoginForm({super.key});

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

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

  void _submit() {
    if (_formKey.currentState!.validate()) {
      final email = _emailController.text.trim();
      debugPrint('Signing in as $email');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            keyboardType: TextInputType.emailAddress,
            decoration: const InputDecoration(labelText: 'Email'),
            validator: (value) {
              if (value == null || !value.contains('@')) {
                return 'Enter a valid email';
              }
              return null;
            },
          ),
          TextFormField(
            controller: _passwordController,
            obscureText: true,
            decoration: const InputDecoration(labelText: 'Password'),
            validator: (value) =>
                (value ?? '').length < 8 ? 'Use at least 8 characters' : null,
          ),
          ElevatedButton(onPressed: _submit, child: const Text('Sign in')),
        ],
      ),
    );
  }
}

How validation runs

validate() calls every field's validator. A validator returns null when the value is fine and an error string otherwise; the string appears under the field automatically. validate() returns true only when all validators returned null. Set autovalidateMode: AutovalidateMode.onUserInteraction on the form if you want errors to update as the user types after their first attempt.

Reading and reacting to input

Use the controller when you need the value at submit time. Use onChanged when the screen should react while typing, for example enabling the button only when both fields have text. A second worked example: a search box that filters a list.

TextField(
  decoration: const InputDecoration(hintText: 'Search jobs'),
  onChanged: (text) {
    setState(() {
      _query = text.toLowerCase();
    });
  },
)

Common mistakes and a practice habit

  • Creating the GlobalKey or controller inside build. Each rebuild makes a new one and the form loses its state.
  • Forgetting dispose(), which leaks the controller and its listeners.
  • Using a TextField inside a Form and expecting validate() to check it.
  • Forgetting trim(), so a trailing space fails an email check.

Practice habit: build this form from a blank file, then add a confirm-password field whose validator compares against the first controller's text. Doing it three times without notes is the goal.

Advertisement
Continue with Flutter4 lessons · 1 interview set · 1 test · 1 certificate
Not tested

More in Web and app