How do avoid markNeedsBuilder() error using Flutter_Riverpod and TextEditingControllers on TextFormFields?

949

Solution 1

assertion was thrown while dispatching notifications for TextEditingController: setState() or markNeedsBuild() called during build.

This error is shown when you update a CahngeNotifier inside a build method, in this case TextEditingController is updated when you're building the widgets:

firstNameController.text = data.firstName;
lastNameController.text = data.lastName;
....

As you mentioned, hooks_riverpod could be an option, but if you don't want to flood yourself with libraries until fully understand riverpod or state management I would recommend 2 approaches:

Try using ProviderListener (part of flutter_riverpod):

class AccountSetup extends StatefulWidget {
  @override
  _AccountSetupState createState() => _AccountSetupState();
}

class _AccountSetupState extends State<AccountSetup> {
  final TextEditingController _firstNameController = TextEditingController();
  final TextEditingController _lastNameController = TextEditingController();

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    _firstNameController.dispose();
    _lastNameController.dispose();
    super.dispose();
  }

  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        backgroundColor: Colors.white,
        body: Form(
          key: _formKey,
          child: ListView(
            children: [
              AccountSettingsTitle(
                title: 'Account Setup',
              ),
              ProviderListener<AsyncValue>(
                provider: accountStreamProvider,
                onChange: (context, account) { //This will called when accountStreamProvider updates and a frame after the widget rebuilt
                  if(account is AsyncData) {
                    firstNameController.text = data.firstName;
                    lastNameController.text = data.lastName;
                  }
                },
                child: FirstLastName(_firstNameController, _lastNameController),
              ),
              SizedBox(
                height: 24.0,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Or you can use it inside FirstLastName and wrap the widget result, it should work the same (remember to delete the lines firstNameController.text = data.firstName; and lastNameController.text = data.lastName; inside when.data to prevent the error)

@override
  Widget build(BuildContext context, ScopedReader watch) {
    final account = watch(accountStreamProvider);
    return ProviderListener<AsyncValue>(
      provider: accountStreamProvider,
      onChange: (context, account) { //This will called when accountStreamProvider updates and a frame after the widget rebuilt
        if(account is AsyncData) {
           firstNameController.text = data.firstName;
           lastNameController.text = data.lastName;
        }
      },
      child: account.maybeWhen(
        data: (data) {
          /// don't call firstNameController.text = data.firstName here
          return Column(
             children: [
                ....
             ],
          );
        },
        orElse: () => Container(),
      ),
    );
  }
}

The other option is to create your own TextEditingController using riverpod and update it with the data of the stream when its created:

final firstNameProvider = ChangeNotifierProvider.autoDispose<TextEditingController>((ref) {
  final account = ref.watch(accountStreamProvider);
  final String name = account.maybeWhen(
     data: (data) => data?.firstName,
     orElse: () => null,
  );
  return TextEditingController(text: name);
});

final lastNameProvider = ChangeNotifierProvider.autoDispose<TextEditingController>((ref) {
  final account = ref.watch(accountStreamProvider);
  final String lastName = account.maybeWhen(
     data: (data) => data?.lastName,
     orElse: () => null,
  );
  return TextEditingController(text: lastName);
});

Then instead of creating them in the parent StatefulWidget just call it from the consumer in FirstLastName(); (there is no need to pass TextEditingControllers in the constructor anymore)

class FirstLastName extends ConsumerWidget {
  const FirstLastName({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final account = watch(accountStreamProvider);
    return account.maybeWhen(
      data: (data) {
        return Column(
          children: [
            Center(
              child: Padding(
                padding: EdgeInsets.only(top: 10.0, left: 24.0, right: 24.0),
                child: Consumer(
                  builder: (context, watch, child) {
                     final firstNameController = watch(firstNameProvider); //call it here
                     return TextFormField(
                       controller: firstNameController,
                       decoration: kInputStringFields.copyWith(
                         hintText: 'First Name',
                       ),
                       autocorrect: false,
                       validator: (String value) {
                         if (value.isEmpty) {
                          return 'Enter first name';
                         }
                         return null;
                       },
                    );
                  }
                ),
              ),
            ),
            SizedBox(
              height: 14.0,
            ),
            Center(
              child: Padding(
                padding: EdgeInsets.only(top: 10.0, left: 24.0, right: 24.0),
                child: child: Consumer(
                  builder: (context, watch, child) {
                     final lastNameController = watch(lastNameProvider); //call it here
                     return TextFormField(
                       controller: lastNameController ,
                       decoration: kInputStringFields.copyWith(
                         hintText: 'LAst Name',
                       ),
                       autocorrect: false,
                       validator: (String value) {
                         if (value.isEmpty) {
                          return 'Enter first name';
                         }
                         return null;
                       },
                    );
                  }
                ),
              ),
            ),
          ],
        );
      },
      orElse: () => Container(),
    );
  }
}

Solution 2

The problem is that you trigger rebuild of your widget during its build method execution with this lines:

firstNameController.text = data.firstName;
lastNameController.text = data.lastName;

Hovewer, solution is quite simple. Just wrap it with zero-delayed Future:

Future.delayed(Duration.zero, (){
firstNameController.text = data.firstName;
lastNameController.text = data.lastName;
});

Basically, always when you see this error, you need to find the code that trigger rebuild during build and wrap it in Future

Share:
949
Zelf
Author by

Zelf

Updated on December 27, 2022

Comments

  • Zelf
    Zelf over 1 year

    The form below is using ConsumerWidget from the flutter_riverpod package to watch for updates on first/last name fields in a firebase stream provider. Then using TextEditingControllers I am both setting the watched text values in the fields and also getting the text values when I update the account in Firebase.

    This all works great until I change a value in the first or last name fields directly in Firebase, which causes a rebuild in the ui. While the UI does display the update Firebase value I get the following Exception in the run logs.

    Appears riverpod is battling with the TextEditingControllers over state, which makes sense, but how do I overcome this?

    ======== Exception caught by foundation library ==================================================== The following assertion was thrown while dispatching notifications for TextEditingController: setState() or markNeedsBuild() called during build.

    This Form widget cannot be marked as needing to build because the framework is already in the process of building widgets. A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase. The widget on which setState() or markNeedsBuild() was called was: Form-[LabeledGlobalKey#78eaf] state: FormState#7d070 The widget which was currently being built when the offending call was made was: FirstLastName dirty dependencies: [UncontrolledProviderScope]

    Can I use the flutter_riverpod package when I am using a Stateful Widget that is required for using TextEditingControllers? Or do I need to look at using the hooks_riverpod package or just riverpod package so that I can use TextEditingControllers to set values in fields and read values from fields?

    Code excerpts below:

    account_setup.dart

    class AccountSetup extends StatefulWidget {
      @override
      _AccountSetupState createState() => _AccountSetupState();
    }
    
    class _AccountSetupState extends State<AccountSetup> {
      final TextEditingController _firstNameController = TextEditingController();
      final TextEditingController _lastNameController = TextEditingController();
    
      @override
      void initState() {
        super.initState();
      }
    
      @override
      void dispose() {
        _firstNameController.dispose();
        _lastNameController.dispose();
        super.dispose();
      }
    
      final _formKey = GlobalKey<FormState>();
    
      @override
      Widget build(BuildContext context) {
        return SafeArea(
          child: Scaffold(
            backgroundColor: Colors.white,
            body: Form(
              key: _formKey,
              child: ListView(
                children: [
                  AccountSettingsTitle(
                    title: 'Account Setup',
                  ),
                  FirstLastName(_firstNameController, _lastNameController),
                  SizedBox(
                    height: 24.0,
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }
    
    class FirstLastName extends ConsumerWidget {
      FirstLastName(
        this.firstNameController,
        this.lastNameController,
      );
      final TextEditingController firstNameController;
      final TextEditingController lastNameController;
    
      @override
      Widget build(BuildContext context, ScopedReader watch) {
        final account = watch(accountStreamProvider);
        return account.when(
          data: (data) {
            firstNameController.text = data.firstName;
            lastNameController.text = data.lastName;
            return Column(
              children: [
                Center(
                  child: Padding(
                    padding: EdgeInsets.only(top: 10.0, left: 24.0, right: 24.0),
                    child: TextFormField(
                      controller: firstNameController,
                      decoration: kInputStringFields.copyWith(
                        hintText: 'First Name',
                      ),
                      autocorrect: false,
                      validator: (String value) {
                        if (value.isEmpty) {
                          return 'Enter first name';
                        }
    
                        return null;
                      },
                    ),
                  ),
                ),
                SizedBox(
                  height: 14.0,
                ),
                Center(
                  child: Padding(
                    padding: EdgeInsets.only(top: 10.0, left: 24.0, right: 24.0),
                    child: TextFormField(
                      controller: lastNameController,
                      decoration: kInputStringFields.copyWith(
                        hintText: 'Last Name',
                      ),
                      autocorrect: false,
                      validator: (String value) {
                        if (value.isEmpty) {
                          return 'Enter last name';
                        }
    
                        return null;
                      },
                    ),
                  ),
                ),
              ],
            );
          },
          loading: () => Container(),
          error: (_, __) => Container(),
        );
      }
    }
    

    top_level_providers.dart

    final accountStreamProvider = StreamProvider.autoDispose<Account>((ref) {
      final database = ref.watch(databaseProvider);
      return database != null ? database.accountStream() : const Stream.empty();
    });
    
  • Zelf
    Zelf about 3 years
    This answer is right as well and the easiest fix. Thank you. I awarded the answer, however, to the above solution simply because there is a lot of great options there. Your answer works great too though and makes sense.
  • Zelf
    Zelf about 3 years
    if(account is AsyncData) { firstNameController.text = data.firstName; lastNameController.text = data.lastName; } "data" is not a parameter, how would I access the stream data?
  • EdwynZN
    EdwynZN about 3 years
    My bad, instead of data.firstName try account.data?.value?.firstName
  • tazboy
    tazboy about 2 years
    Similar to suggested fix on Riverpod's GitHub. github.com/rrousselGit/river_pod/issues/…