dartriverpodthemedata

How to prevent riverpod's ConsumerWidget rebuilds managing ThemeMode


I'm managing the ThemeMode of my flutter application with Riverpod state Provider that works as expected up until I try to read Theme.of(context) to get ThemeData's current values which causes rebuilding of the widget in excess (13~14 times in a row). So I decided create a provider for ThemeData following Riverpod's repository example but I'm still getting these unecessary rebuilds. How can I prevent these unnecessary riverpod rebuilds to get ThemeData? and why is it happening?

This code is available on github.

main app:

final themeProvider = Provider<ThemeData>(
  (ref) => throw UnimplementedError(),
  dependencies: const [],
);

void main() {
  runApp(const ProviderScope(child: MainApp()));
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ThemeMode themeMode = ref.watch(themeModeStateProvider);

    if (kDebugMode) {
      print("building app");
    }

    return MaterialApp(
      theme: FlexThemeData.light(scheme: FlexScheme.mandyRed),
      darkTheme: FlexThemeData.dark(scheme: FlexScheme.mandyRed),
      themeMode: themeMode,
      builder: (context, child) {
        final theme = Theme.of(context);
        return ProviderScope(
          overrides: [
            themeProvider.overrideWithValue(theme),
          ],
          child: child!,
        );
      },
      home: const HomeScreen(),
    );
  }
}

ThemeMode Provider:

@riverpod
class ThemeModeState extends _$ThemeModeState {
  @override
  ThemeMode build() {
    return ThemeMode.dark;
  }

  static ThemeMode getSystemTheme(BuildContext context) {
    ThemeMode mode = ThemeMode.system;
    if (mode == ThemeMode.system) {
      if (MediaQuery.of(context).platformBrightness == Brightness.light) {
        mode = ThemeMode.light;
      } else {
        mode = ThemeMode.dark;
      }
    }
    return mode;
  }

  void toggleThemeMode() {
    if (state == ThemeMode.dark) {
      state = ThemeMode.light;
    } else {
      state = ThemeMode.dark;
    }
  }
}

homescreen:

class HomeScreen extends ConsumerWidget {
  static String routeName = "home";

  const HomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ThemeData themeData = ref.watch(themeProvider);
    final TextStyle headlineMedium = themeData.textTheme.headlineLarge!;

    if (kDebugMode) {
      print("building home");
    }

    return Scaffold(
      body: Center(
        child: Column(
          children: [
            Text(
              "Hello World",
              style: headlineMedium,
            ),
            SwitchListTile(
              contentPadding: EdgeInsets.zero,
              title: const Text('Theme mode'),
              value: ref.watch(themeModeStateProvider) == ThemeMode.light,
              onChanged: (value) {
                ref.watch(themeModeStateProvider.notifier).toggleThemeMode();
              },
            ),
          ],
        ),
      ),
    );
  }
}

Solution

  • I am attaching a shorter code to reproduce this problem (without using generation):

    import 'package:flutter_riverpod/flutter_riverpod.dart';
    import 'package:flutter/material.dart';
    
    final themeProvider = Provider<ThemeData>(
      (ref) => throw UnimplementedError(),
      dependencies: const [],
    );
    
    void main() {
      runApp(const ProviderScope(child: MainApp()));
    }
    
    class MainApp extends ConsumerWidget {
      const MainApp({super.key});
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final ThemeMode themeMode = ref.watch(themeModeStateProvider);
        print("#building app");
    
        return MaterialApp(
          theme: ThemeData.light(),
          darkTheme: ThemeData.dark(),
          themeMode: themeMode,
          builder: (context, child) {
            print("##building builder");
            final theme = Theme.of(context);
            return ProviderScope(
              overrides: [themeProvider.overrideWithValue(theme)],
              child: child!,
            );
          },
          home: const HomeScreen(),
        );
      }
    }
    
    class HomeScreen extends ConsumerWidget {
      const HomeScreen({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final ThemeData themeData = ref.watch(themeProvider);
        print("###building home");
    
        return Scaffold(
          body: SwitchListTile(
            title: const Text('Theme mode'),
            value: ref.watch(themeModeStateProvider) == ThemeMode.light,
            onChanged: (value) {
              ref.read(themeModeStateProvider.notifier).toggleThemeMode();
            },
          ),
        );
      }
    }
    
    final themeModeStateProvider =
        AutoDisposeNotifierProvider<ThemeModeState, ThemeMode>(
      ThemeModeState.new,
    );
    
    class ThemeModeState extends AutoDisposeNotifier<ThemeMode> {
      @override
      ThemeMode build() => ThemeMode.dark;
    
      void toggleThemeMode() {
        if (state == ThemeMode.dark) {
          state = ThemeMode.light;
        } else {
          state = ThemeMode.dark;
        }
      }
    }
    

    By the way, don't use ref.watch in widget lifecycle management methods and callbacks. Use ref.read instead:

    onChanged: (value) {
      ref.read(themeModeStateProvider.notifier).toggleThemeMode();
    },
    

    Your problem lies in the MainApp widget, specifically in the builder parameter. The short solution to the problem is to not use of(context) inside builder, and it looks like this:

      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final ThemeMode themeMode = ref.watch(themeModeStateProvider);
    
        final theme = themeMode == ThemeMode.light 
          ? ThemeData.light() 
          : ThemeData.dark();
    
        return MaterialApp(
          theme: theme,
          darkTheme: theme,
          themeMode: themeMode,
          builder: (context, child) {
            return ProviderScope(
              overrides: [themeProvider.overrideWithValue(theme)],
              child: child!,
            );
          },
          home: const HomeScreen(),
        );
      }
    

    Now your rebuilds are optimized.

    Speaking for the future, most likely your ThemeData should also have a full-fledged NotifierProvider and inside the build method elegantly watch to the current themeModeStateProvider. Then the ProviderScope -> overrideWithValue construct is not useful at all.

    Well, the long solution is to write an issue to the flutter repository.


    The final version, taking into account Localizations, will look like this:

      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final ThemeMode themeMode = ref.watch(themeModeStateProvider);
        final ThemeData themeLight =
            FlexThemeData.light(scheme: FlexScheme.mandyRed);
        final ThemeData themeDark = FlexThemeData.dark(scheme: FlexScheme.mandyRed);
        final ThemeData themeData = (themeMode == ThemeMode.light)
            ? localizeThemeData(context, themeLight)
            : localizeThemeData(context, themeDark);
            
        return MaterialApp(
          theme: themeLight,
          darkTheme: themeDark,
          themeMode: themeMode,
          builder: (context, child) {
            return ProviderScope(
              overrides: [themeProvider.overrideWithValue(themeData)],
              child: child!,
            );
          },
          home: const HomeScreen(),
        );
    
      static ThemeData localizeThemeData(BuildContext context, ThemeData themeData) {
        final MaterialLocalizations? localizations =
            Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
        final ScriptCategory category =
            localizations?.scriptCategory ?? ScriptCategory.englishLike;
        return ThemeData.localize(
            themeData, themeData.typography.geometryThemeFor(category));
      }