Marco
Marco

Reputation: 593

Flutter | Riverpod & Dart Unhandled Exception: setState() or markNeedsBuild() called during build

Context

I have this AppUser class:

@immutable
class AppUser {
  const AppUser({
    this.displayName,
    this.email,
    required this.emailVerified,
    this.phoneNumber,
    this.photoURL,
    required this.uid,
  });

  AppUser.fromFirebaseUser(User user)
      : displayName = user.displayName,
        email = user.email,
        emailVerified = user.emailVerified,
        phoneNumber = user.phoneNumber,
        photoURL = user.photoURL,
        uid = user.uid;

  final String? displayName;
  final String? email;
  final bool emailVerified;
  final String? phoneNumber;
  final String? photoURL;
  final String uid;
}

In order to manage and use the current user signed in, I have this AppUserController class:

class AppUserController extends StateNotifier<AppUser> {
  AppUserController()
      : super(
          const AppUser(
            emailVerified: false,
            uid: '',
          ),
        );

  Stream<User?> get onAuthStateChanges =>
      FirebaseAuth.instance.authStateChanges();

  set setAppUser(AppUser appUser) {
    state = appUser;
  }

  Future<void> signOut() async {
    await FirebaseAuth.instance.signOut();
  }
}

Then, I created 2 providers:

final appUserProvider =
    StateNotifierProvider<AppUserController, AppUser>((ref) {
  return AppUserController();
});

final appUserStreamProvider = StreamProvider<AppUser?>((ref) {
  return ref
      .read(appUserProvider.notifier)
      .onAuthStateChanges
      .map<AppUser?>((user) {
    return user != null ? AppUser.fromFirebaseUser(user) : null;
  });
});

I need to manage a user’s budgets list. Also, I have to synchronize this list with a Cloud Firestore database, so I created the BudgetsService class:

class BudgetsService {
  BudgetsService({
    required this.uid,
  }) : budgetsRef = FirebaseFirestore.instance
            .collection(FirestorePath.budgetsCollection(uid))
            .withConverter<Budget>(
              fromFirestore: (snapshot, _) => Budget.fromMap(snapshot.data()!),
              toFirestore: (budget, _) => budget.toMap(),
            );

  String uid;
  final CollectionReference<Budget> budgetsRef;

  Future<void> addUpdate(Budget budget) async {
    await budgetsRef.doc(documentPath(budget)).set(budget);
  }

  Future<void> delete(Budget budget) async {
    await budgetsRef.doc(documentPath(budget)).delete();
  }

  String documentPath(Budget budget) => FirestorePath.budgetDoc(uid, budget);

  Future<List<Budget>> getBudgets() async {
    final list = await budgetsRef.get();

    return list.docs.map((e) => e.data()).toList();
  }
}

I use this class through budgetsServiceProvider provider:

final budgetsServiceProvider = Provider<BudgetsService>((ref) {
  final AppUser appUser = ref.watch(appUserProvider);
  final String uid = appUser.uid;

  return BudgetsService(uid: uid);
});

I use BudgetsService class only to interact with the online database. For the rest, I manage the user’s budget list with BudgetsController class:

class BudgetsController extends StateNotifier<List<Budget>> {
  BudgetsController() : super(<Budget>[]);

  List<String> get names => state.map((b) => b.name).toList();

  Future<void> addUpdate(Budget budget, BudgetsService budgetsService) async {
    await budgetsService.addUpdate(budget);

    if (budgetAlreadyExists(budget)) {
      final int index = indexOf(budget);
      final List<Budget> newState = [...state];
      newState[index] = budget;
      state = newState..sort();
    } else {
      state = [...state, budget]..sort();
    }
  }

  bool budgetAlreadyExists(Budget budget) => names.contains(budget.name);

  Future<void> delete(Budget budget, BudgetsService budgetsService) async {
    await budgetsService.delete(budget);

    final int index = indexOf(budget);
    if (index != -1) {
      final List<Budget> newState = [...state]
        ..removeAt(index)
        ..sort();
      state = newState;
    }
  }

  Future<void> retrieveBudgets(BudgetsService budgetsService) async {
    state = await budgetsService.getBudgets();
  }

  int indexOf(Budget budget) => state.indexWhere((b) => b.name == budget.name);
}

I use this class through budgetsProvider provider:

final budgetsProvider =
    StateNotifierProvider<BudgetsController, List<Budget>>((ref) {
  return BudgetsController();
});

After the user is signed in, my SwitchScreen widget navigates to ConsoleScreen:

class SwitchScreen extends HookWidget {
  const SwitchScreen({
    Key? key,
  }) : super(key: key);

  static const route = '/switch';

  @override
  Widget build(BuildContext context) {
    final appUserStream =
        useProvider<AsyncValue<AppUser?>>(appUserStreamProvider);
    final googleSignIn =
        useProvider<GoogleSignInService>(googleSignInServiceProvider);
    final appUserController =
        useProvider<AppUserController>(appUserProvider.notifier);

    return appUserStream.when(
      data: (data) {
        if (data != null) {
          appUserController.setAppUser = data;

          final budgetsService = useProvider(budgetsServiceProvider);

          return const ConsoleScreen();
        } else {
          return SignInScreen(
            onGooglePressed: googleSignIn.signInWithGoogle,
          );
        }
      },
      loading: () {
        return const Scaffold(
          body: Center(
            child: LinearProgressIndicator(),
          ),
        );
      },
      error: (error, stack) {
        return Scaffold(
          body: Center(
            child: Text('Error: $error'),
          ),
        );
      },
    );
  }
}

Problem

The first time I build the app, I have no problem. But when I perform the hot reload, I get the following error message:

══════ Exception caught by widgets library ═══════════════════════════════════
The following Error was thrown building SwitchScreen(dirty, dependencies: [UncontrolledProviderScope], AsyncValue<AppUser?>.data(value: Instance of 'AppUser'), Instance of 'GoogleSignInService', Instance of 'AppUserController'):
Instance of 'Error'

The relevant error-causing widget was
SwitchScreen
lib\main.dart:67
When the exception was thrown, this was the stack
#0      StateNotifier.state=
package:state_notifier/state_notifier.dart:173
#1      AppUserController.setAppUser=
package:financesmanager/controllers/app_user_controller.dart:42
#2      SwitchScreen.build.<anonymous closure>
package:financesmanager/screens/switch_screen.dart:33
#3      _$AsyncData.when
package:riverpod/src/common.freezed.dart:148
#4      SwitchScreen.build
package:financesmanager/screens/switch_screen.dart:28
...
════════════════════════════════════════════════════════════════════════════════
E/flutter (13932): [ERROR:flutter/shell/common/shell.cc(103)] Dart Unhandled Exception: setState() or markNeedsBuild() called during build.
E/flutter (13932): This UncontrolledProviderScope 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.
E/flutter (13932): The widget on which setState() or markNeedsBuild() was called was:
E/flutter (13932):   UncontrolledProviderScope
E/flutter (13932): The widget which was currently being built when the offending call was made was:
E/flutter (13932):   SwitchScreen, stack trace: #0      Element.markNeedsBuild.<anonymous closure>
package:flutter/…/widgets/framework.dart:4217
E/flutter (13932): #1      Element.markNeedsBuild
package:flutter/…/widgets/framework.dart:4232
E/flutter (13932): #2      ProviderElement._debugMarkWillChange.<anonymous closure>
package:riverpod/…/framework/base_provider.dart:660
E/flutter (13932): #3      ProviderElement._debugMarkWillChange
package:riverpod/…/framework/base_provider.dart:664
E/flutter (13932): #4      ProviderStateBase.exposedValue=.<anonymous closure>
package:riverpod/…/framework/base_provider.dart:900
E/flutter (13932): #5      ProviderStateBase.exposedValue=
package:riverpod/…/framework/base_provider.dart:902
E/flutter (13932): #6      _StateNotifierProviderState._listener
package:riverpod/src/state_notifier_provider.dart:92
E/flutter (13932): #7      StateNotifier.state=
package:state_notifier/state_notifier.dart:162
E/flutter (13932): #8      AppUserController.setAppUser=
package:financesmanager/controllers/app_user_controller.dart:42
E/flutter (13932): #9      SwitchScreen.build.<anonymous closure>
package:financesmanager/screens/switch_screen.dart:33

Question

How can I solve the problem?

Thank you very much!

Update (2021-06-08)

In my main.dart file I have:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  runApp(ProviderScope(child: FMApp()));
}

class FMApp extends HookWidget {
  FMApp({
    Key? key,
  }) : super(key: key);

  final Future<FirebaseApp> _initialization = Firebase.initializeApp();

  @override
  Widget build(BuildContext context) {
    final darkTheme = AppTheme.theme(Brightness.dark);
    final lightTheme = AppTheme.theme(Brightness.light);
    final isLightTheme = useProvider<bool>(themePreferenceProvider);
    final theme = isLightTheme ? lightTheme : darkTheme;

    return FutureBuilder(
      future: _initialization,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return FlutterFireInitErrorScreen(
            appTitle: 'FM App',
            darkTheme: darkTheme,
            error: snapshot.error,
            theme: theme,
          );
        }

        if (snapshot.connectionState == ConnectionState.done) {
          return MaterialApp(
            debugShowCheckedModeBanner: false,
            title: 'FM App',
            localizationsDelegates: const [
              AppLocalizations.delegate,
              GlobalMaterialLocalizations.delegate,
              GlobalWidgetsLocalizations.delegate,
              GlobalCupertinoLocalizations.delegate,
            ],
            supportedLocales: const [
              Locale.fromSubtags(languageCode: 'en'),
              Locale.fromSubtags(languageCode: 'es'),
              Locale.fromSubtags(languageCode: 'it'),
            ],
            darkTheme: darkTheme,
            theme: theme,
            initialRoute: SwitchScreen.route,
            routes: {
              SwitchScreen.route: (context) => const SwitchScreen(),
            },
          );
        }

        return FlutterFireInitWaitingScreen(
          appTitle: 'FM App',
          darkTheme: darkTheme,
          theme: theme,
        );
      },
    );
  }
}

Possible solution

For now I solved it by replacing, in switch_screen.dart file, this code:

final budgetsService = useProvider(budgetsServiceProvider);

final budgetsController = context.read<BudgetsController>(budgetsProvider.notifier);
budgetsController.retrieveBudgets(budgetsService);

with the following:

final budgetsService = BudgetsService(uid: data.uid);
context
    .read(budgetsControllerProvider)
    .retrieveBudgets(budgetsService);

What do you think? Is this a good solution? Is there a better one? Thank you!

Upvotes: 0

Views: 1529

Answers (1)

Hikeland
Hikeland

Reputation: 416

The interpretation of the error is that two widgets are updating at the same time, probably because they watch the same provider.

When a Child Widget tries to rebuild while its Parent Widget also tries to rebuild, it generates this error. To solve this error, only the Parent Widget needs to rebuild, because the Child Widget will automatically rebuild.

Unfortunately, in the code you provide, I cannot see from where your SwitchScreen is displayed so I cannot tell you where the exact problem could be.

Upvotes: 2

Related Questions