Skip to content

FormField not showing error when invalid #179794

Open

Description

@SamuelGadiel

Steps to reproduce

  1. Create a flutter project;
  2. Paste the code below in the main.dart;
  3. Run the project;
  4. With the radio buttons unmarked, press the Validate button;
  5. Check that no error message was displayed;
  6. Check the logs for analysis

Expected results

We would expect that, when validating an invalid field, a message would be presented below the field.
The errorBuilder is optional. If we want to personalize how the error message is shown, we could use the errorBuilder to provide a custom widget for it.
But the errorBuilder wouldn't be required for the message to be shown.

Actual results

In both examples, with or without the errorBuilder, the error message is not displayed below the field

Code sample

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: MyHomePage());
  }
}

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

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final _formKey = GlobalKey<FormState>();

  bool? _withoutErrorBuilder;
  bool? _withErrorBuilder;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Form(
          key: _formKey,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('Field without errorBuilder'),
              FormField<bool>(
                initialValue: _withoutErrorBuilder,
                validator: (value) {
                  debugPrint('[Without errorBuilder] validator was called with: $value');
                  return value == null ? 'Select an option' : null;
                },
                builder: (state) {
                  return RadioGroup<bool>(
                    groupValue: _withoutErrorBuilder,
                    onChanged: (value) {
                      setState(() => _withoutErrorBuilder = value);
                      state.didChange(value);
                    },
                    child: const Row(
                      children: [
                        Expanded(child: RadioListTile<bool>(title: Text('Option 1'), value: true)),
                        Expanded(child: RadioListTile<bool>(title: Text('Option 2'), value: false)),
                      ],
                    ),
                  );
                },
              ),

              SizedBox(height: 64),

              Text('Field with errorBuilder'),
              FormField<bool>(
                initialValue: _withErrorBuilder,
                validator: (value) {
                  debugPrint('[With errorBuilder] validator was called with: $value');
                  return value == null ? 'Select an option' : null;
                },
                errorBuilder: (context, errorText) {
                  debugPrint('[With errorBuilder] errorBuilder was called with: $errorText');
                  return Text(errorText, style: const TextStyle(fontSize: 12));
                },
                builder: (state) {
                  return RadioGroup<bool>(
                    groupValue: _withErrorBuilder,
                    onChanged: (value) {
                      setState(() => _withErrorBuilder = value);
                      state.didChange(value);
                    },
                    child: const Row(
                      children: [
                        Expanded(child: RadioListTile<bool>(title: Text('Option 1'), value: true)),
                        Expanded(child: RadioListTile<bool>(title: Text('Option 2'), value: false)),
                      ],
                    ),
                  );
                },
              ),

              const SizedBox(height: 36),
              ElevatedButton(
                onPressed: () {
                  final isValid = _formKey.currentState!.validate();

                  debugPrint('[Without errorBuilder] value: $_withoutErrorBuilder');
                  debugPrint('[With errorBuilder] value: $_withErrorBuilder');
                  debugPrint('Form is valid: $isValid');
                },
                child: const Text('Validate'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration

This print was taken after the button was pressed. No message was displayed below the field.
Image

Logs

Logs
I/flutter (25118): ---------------------- Validation ----------------------
I/flutter (25118): [Without errorBuilder] validator was called with: null
I/flutter (25118): [With errorBuilder] validator was called with: null
I/flutter (25118): 
I/flutter (25118): ---------------------- Result ----------------------
I/flutter (25118): [Without errorBuilder] value: null
I/flutter (25118): [With errorBuilder] value: null
I/flutter (25118): Form is valid: false

Flutter Doctor output

Doctor output
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.38.4, on macOS 15.6.1 24G90 darwin-arm64, locale pt-BR)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0-rc1)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Connected device (3 available)
[✓] Network resources

• No issues found!

Activity

  1. MuhammadMashoodSiddiquie commented on Dec 15, 2025

    @MuhammadMashoodSiddiquie

    I see the issue persists, Inside the builder we have state which is getting the error text correctly from validator, but the errorBuilder doesn't get the correct value, Below is the code with the state.errorText as well.

    I am using Flutter 3.38.3, Dart 3.10.1.

    Following the code attached FYI:

    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          debugShowCheckedModeBanner: false,
          home: MyHomePage(),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({super.key});
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      final _formKey = GlobalKey<FormState>();
    
      bool? _withoutErrorBuilder;
      bool? _withErrorBuilder;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Form(
              key: _formKey,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text('Field without errorBuilder'),
                  const SizedBox(height: 8),
    
                  FormField<bool?>(
                    initialValue: _withoutErrorBuilder,
                    validator: (value) {
                      debugPrint(
                        '[Without errorBuilder] validator called with: $value',
                      );
                      return value == null ? 'Select an option' : null;
                    },
                    builder: (state) {
                      return Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          RadioGroup<bool>(
                            groupValue: _withoutErrorBuilder,
                            onChanged: (value) {
                              setState(() => _withoutErrorBuilder = value);
                              state.didChange(value);
                            },
                            child: const Row(
                              children: [
                                Expanded(
                                  child: RadioListTile<bool>(
                                    title: Text('Option 1'),
                                    value: true,
                                  ),
                                ),
                                Expanded(
                                  child: RadioListTile<bool>(
                                    title: Text('Option 2'),
                                    value: false,
                                  ),
                                ),
                              ],
                            ),
                          ),
    
                          if (state.hasError)
                            Padding(
                              padding: const EdgeInsets.only(left: 16, top: 4),
                              child: Text(
                                "Coming from builder: ${state.errorText!}",
                                style: const TextStyle(
                                  color: Colors.red,
                                  fontSize: 12,
                                ),
                              ),
                            ),
                        ],
                      );
                    },
                  ),
    
                  const SizedBox(height: 48),
    
                  const Text('Field with errorBuilder'),
    
                  const SizedBox(height: 8),
    
                  FormField<bool?>(
                    initialValue: _withErrorBuilder,
                    validator: (value) {
                      debugPrint(
                        '[With errorBuilder] validator called with: $value',
                      );
                      return value == null ? 'Select an option' : null;
                    },
                    errorBuilder: (context, errorText) {
                      debugPrint(
                        '[With errorBuilder] errorBuilder called: ${errorText}',
                      );
                      return Center(
                        child: Padding(
                          padding: const EdgeInsets.only(left: 16, top: 4),
                          child: Text(
                            "Coming from errorBuilder: ${errorText}",
                            style: const TextStyle(color: Colors.red, fontSize: 12),
                          ),
                        ),
                      );
                    },
                    builder: (state) {
                      return Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          RadioGroup<bool>(
                            groupValue: _withErrorBuilder,
                            onChanged: (value) {
                              setState(() => _withErrorBuilder = value);
                              state.didChange(value);
                            },
                            child: const Row(
                              children: [
                                Expanded(
                                  child: RadioListTile<bool>(
                                    title: Text('Option 1'),
                                    value: true,
                                  ),
                                ),
                                Expanded(
                                  child: RadioListTile<bool>(
                                    title: Text('Option 2'),
                                    value: false,
                                  ),
                                ),
                              ],
                            ),
                          ),
                          if (state.hasError)
                            Padding(
                              padding: const EdgeInsets.only(left: 16, top: 4),
                              child: Text(
                                "Coming from builder: ${state.errorText!}",
                                style: const TextStyle(
                                  color: Colors.red,
                                  fontSize: 12,
                                ),
                              ),
                            ),
                        ],
                      );
                    },
                  ),
    
                  const SizedBox(height: 36),
    
                  ElevatedButton(
                    onPressed: () {
                      final isValid = _formKey.currentState!.validate();
                      debugPrint('Form valid: $isValid');
                      if (isValid) {
                        debugPrint(
                          '[Without errorBuilder] value: $_withoutErrorBuilder',
                        );
                        debugPrint('[With errorBuilder] value: $_withErrorBuilder');
                      }
                    },
                    child: const Text('Validate'),
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }
    ```
    
  2. darshankawar commented on Dec 15, 2025

    @darshankawar
    Member

    Thanks for the report. Seeing the same behavior as reported with latest sdk versions.

    
    stable: 3.38.4
    master: 3.40.0-1.0.pre-165
    
  3. added
    frameworkflutter/packages/flutter repository. See also f: labels.
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    and removed
    in triagePresently being triaged by the triage team
    on Dec 15, 2025
  4. bleroux commented on Dec 15, 2025

    @bleroux
    Contributor

    FormField is a generic widget, it does not have an opinionated way to render UI. It is up to the code provided through FormField.builder to decide how to render the error (for instance, by creating a Column to display the error text below or above the form field content).

    Subclasses such as TextFieldFormField and DropdownMenuFormField comply with the Material specification which states where the error should be shown. Those widgets exposes the errorBuilder property which make it possible to customise the rendering of the error.

  5. MuhammadMashoodSiddiquie commented on Dec 15, 2025

    @MuhammadMashoodSiddiquie

    @bleroux
    So why do we have the errorBuilder inside formField widget?, If the case is to handle the UI of error in builder so what's the purpose of having errorBuilder inside the FormField?

    errorBuilder: (context, errorText) {
                     debugPrint(
                       '[With errorBuilder] errorBuilder called: ${errorText}',
                     );
                     return Center(
                       child: Padding(
                         padding: const EdgeInsets.only(left: 16, top: 4),
                         child: Text(
                           "Coming from errorBuilder: ${errorText}",
                           style: const TextStyle(color: Colors.red, fontSize: 12),
                         ),
                       ),
                     );
                   },
    
  6. bleroux commented on Dec 15, 2025

    @bleroux
    Contributor

    So why do we have the errorBuilder inside formField widget?

    Adding errorBuilder to FormField was probably a mistake. Adding it at the FormField level was convenient, but it would have been better to not add it to FormBuilder and declare it only on subclasses when needed.

    @justinmc WDYT of deprecating FormField.errorBuilder in favor of local parameters in TextFormField and DropdownButtonFormField constructors?

  7. SamuelGadiel commented on Dec 17, 2025

    @SamuelGadiel
    Author

    Thing is, either by using the errorBuilder or not, the message does not automatically appear when there is an invalid state.
    The errorBuilder could come in hand to personalize how the error is displayed, but it isn't necessary for the error message to be presented in the first place.
    Even without the errorBuilder the error message should appear, but that doesn't happen.

    Yeah, we have TextFormField and DropdownButtonFormField, but can also build custom form fields, like i did in the example with the radio buttons. In these situations, building custom fields, we need to rely on FormField.
    And we would expect that by using FormField the error message would appear when in invalid state and, as a bonus, that we could be able to personalize how the message is shown (again, these are two separated things).

    FormField has the base structure for this, it's expected to work like this, following the structure from all of the other fields.
    We shouldn't put it in a Column and present the error manually. This shouldn't be the way to go.

    For me, the correct path is for the FormField to show the error message, just like all the other fields, and to allow for the user to personalize it with the errorBuilder it they want it to.

  8. justinmc commented on Jan 2, 2026

    @justinmc
    Contributor

    I agree that having errorBuilder in FormField but not having it do anything is a confusing API design. I didn't realize it at the time of #162255.

    Maybe errorBuilder (or its return value) should be passed into builder? I think that would work for TextFormField.

  9. self-assigned this
    on Jan 6, 2026
  10. bleroux commented on Jan 13, 2026

    @bleroux
    Contributor

    Maybe errorBuilder (or its return value) should be passed into builder? I think that would work for TextFormField.

    I tried this solution, it means changing the signature of FormFieldBuilder typedef (to add an optional parameter for errorBuilder). The problem is that it requires subclasses to update the signature of the function passed to initialize the FormField.builder field. It would impact any FormField sublasses.

  11. added
    P2Important issues not at the top of the work list
    on Jan 16, 2026
  12. removed their assignment
    on Jan 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listfound in release: 3.38Found to occur in 3.38found in release: 3.40Found to occur in 3.40frameworkflutter/packages/flutter repository. See also f: labels.has reproducible stepsThe issue has been confirmed reproducible and is ready to work onteam-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions