← Back to blog
Mastering Riverpod: Theming · Part 1 of 6
August 30, 20268 min read

Managing Theme State with Riverpod — a NotifierProvider for ThemeMode

RiverpodFlutterDart

Managing Theme State with Riverpod

Welcome to Part 1 of Mastering Riverpod: Theming — the course that takes the static theme from the Flutter Theming Foundations series and makes it alive: user-controlled, persistent, reactive.

In Foundations Part 5 we ended with a hardcoded themeMode: ThemeMode.system. That's a value, not state — the user can't change it. This part fixes that by lifting ThemeMode into a NotifierProvider, so any widget can read it and change it. If you've done the Provider Types series, this is Notifier applied to the single most satisfying use case in app development.

Prerequisite: this series assumes Riverpod 3.0 and the Foundations theming series. If Notifier/NotifierProvider are new to you, read Provider Types Part 4 first — we build directly on it.


The problem: a setting nobody can set

Here's where Foundations left off:

MaterialApp(
  theme: lightTheme,
  darkTheme: darkTheme,
  themeMode: ThemeMode.system, // ← frozen. No UI can change this.
  home: const HomePage(),
)

ThemeMode.system is a literal baked into build. To let a settings screen flip between Light / Dark / System, that value has to become shared, mutable state that the MaterialApp watches and a button can change. That's precisely what Riverpod exists for.

Analogy — the thermostat. Right now the app's temperature is welded to one setting. We're installing a thermostat (ThemeMode state) on the wall: the furnace (MaterialApp) reads the thermostat, and anyone in the house (any widget) can turn the dial. Change the dial in the kitchen and the whole house responds.


Step 1 — the Notifier

We hold a single ThemeMode value and expose methods to change it. This is the Notifier shape: override build() to return the initial state, mutate via state =.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';

class ThemeModeNotifier extends Notifier<ThemeMode> {
  @override
  ThemeMode build() => ThemeMode.system; // sensible default (Foundations Part 5)

  void setMode(ThemeMode mode) => state = mode;

  void toggle() {
    // Flip between light and dark (treating system as "currently light").
    state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
  }
}

final themeModeProvider =
    NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);

Read it slowly — every line is a Foundations/Provider-Types idea applied:

  • build() returns ThemeMode.system, the respectful default from Foundations Part 5.
  • setMode lets a settings screen pick any of the three modes.
  • toggle is the quick "moon/sun button" action.
  • The provider exposes the notifier and its ThemeMode state, created with the ThemeModeNotifier.new tear-off.

Step 2 — make MaterialApp watch it

Now the MaterialApp reads the thermostat instead of hardcoding. Convert your root to a ConsumerWidget and ref.watch the provider:

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final mode = ref.watch(themeModeProvider); // ← rebuilds when the mode changes

    return MaterialApp(
      theme: themeFor(Brightness.light),  // from Foundations Part 5
      darkTheme: themeFor(Brightness.dark),
      themeMode: mode,                    // ← driven by state
      home: const HomePage(),
    );
  }
}

And wrap the app in a ProviderScope at the very top (the Riverpod root):

void main() => runApp(const ProviderScope(child: App()));

That's the entire wiring. ref.watch(themeModeProvider) subscribes the MaterialApp to the state; when the state changes, MaterialApp rebuilds with the new themeMode, and — because every widget reads its colors from the theme roles — the whole app recolors. No setState, no manual listeners.

Why ref.watch here? MaterialApp must rebuild when the mode changes, so it watches. Compare with Foundations vs Riverpod reads: watch for values you render, read for one-off actions.


Step 3 — change it from the UI

Any widget can now flip the theme. In a callback, use ref.read(...notifier) to call a method (never watch just to call a method — that's the Foundation Part 5 rule):

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final mode = ref.watch(themeModeProvider);          // to show the right icon
    final isDark = mode == ThemeMode.dark;

    return IconButton(
      icon: Icon(isDark ? Icons.light_mode : Icons.dark_mode),
      onPressed: () => ref.read(themeModeProvider.notifier).toggle(), // action
    );
  }
}

Tap it and the icon, the AppBar, every surface and text color flip instantly. Here's the same pattern as a three-way settings control:

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final mode = ref.watch(themeModeProvider);
    return SegmentedButton<ThemeMode>(
      segments: const [
        ButtonSegment(value: ThemeMode.light, label: Text('Light')),
        ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
        ButtonSegment(value: ThemeMode.system, label: Text('System')),
      ],
      selected: {mode},
      onSelectionChanged: (s) =>
          ref.read(themeModeProvider.notifier).setMode(s.first),
    );
  }
}

That's a complete, production-shaped theme switcher in ~15 lines.


Why this beats the alternatives

You could manage themeMode with setState and pass callbacks down, or with InheritedWidget, or a global singleton. Why Riverpod?

| Approach | Problem it has | | --- | --- | | setState + callbacks | The mode lives in one State; you prop-drill onThemeChanged through every screen that needs the toggle | | Global mutable variable | No reactivity — changing it doesn't rebuild anything; you bolt on a ChangeNotifier and reinvent providers | | Raw InheritedWidget | Lots of boilerplate; awkward to mutate from below | | NotifierProvider | One declaration; any widget reads or mutates with ref; rebuilds are automatic and scoped |

The payoff: the toggle button doesn't need to be near the MaterialApp. It can live six screens deep in a settings page and still flip the global theme, because it talks to the provider, not to a parent widget. That decoupling is the whole reason to reach for Riverpod here.


The shape we'll grow

Right now the mode resets to system on every launch. Over the next parts we make it real:

  • Part 2: persist the choice with SharedPreferences so it survives restarts.
  • Part 3: the mechanics (and edge cases) of switching at runtime with no restart.
  • Part 4: detect and react to the OS Brightness properly.
  • Part 5: per-feature theme overrides with ProviderScope.

For now you have the spine: ThemeMode is state, MaterialApp watches it, any widget can change it.


Practice Challenges

Challenge 1 — The notifier. Write a ThemeModeNotifier defaulting to system with a setMode method, plus its provider.

Show solution
class ThemeModeNotifier extends Notifier<ThemeMode> {
  @override
  ThemeMode build() => ThemeMode.system;
  void setMode(ThemeMode mode) => state = mode;
}
final themeModeProvider =
    NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);

Challenge 2 — Wire the app. Make MaterialApp use the provider's value for themeMode.

Show solution
final mode = ref.watch(themeModeProvider);
return MaterialApp(
  theme: lightTheme, darkTheme: darkTheme, themeMode: mode, home: const HomePage(),
);

The root must be a ConsumerWidget and the app wrapped in ProviderScope.

Challenge 3 — watch vs read. In a toggle button you need the current mode (for the icon) and to call toggle(). Which ref method for each?

Show solution

ref.watch(themeModeProvider) for the current mode (so the icon updates), and ref.read(themeModeProvider.notifier).toggle() inside onPressed for the action. Never watch the notifier just to call a method.

Challenge 4 — Three-way selector. Add a setMode(ThemeMode) driven SegmentedButton for Light/Dark/System.

Show solution
SegmentedButton<ThemeMode>(
  segments: const [
    ButtonSegment(value: ThemeMode.light, label: Text('Light')),
    ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
    ButtonSegment(value: ThemeMode.system, label: Text('System')),
  ],
  selected: {ref.watch(themeModeProvider)},
  onSelectionChanged: (s) => ref.read(themeModeProvider.notifier).setMode(s.first),
);

Challenge 5 — Decoupling argument. Explain why a settings-page toggle six screens deep can change the global theme without callbacks.

Show solution

It calls ref.read(themeModeProvider.notifier).setMode(...), talking to the provider rather than a parent widget. The MaterialApp separately ref.watches the same provider, so it rebuilds — no onThemeChanged prop-drilling between them.


Questions to test yourself

Q1 (basic). What kind of provider holds ThemeMode, and why not a plain Provider?

Show answer

A NotifierProvider, because the state is mutable (the user changes it). A plain Provider is read-only (Provider Types Part 1) and can't be reassigned from the UI.

Q2 (basic). What does the ThemeModeNotifier's build() return?

Show answer

The initial ThemeMode — typically ThemeMode.system, the respectful default from Foundations Part 5.

Q3 (intermediate). Why must MaterialApp ref.watch the provider rather than ref.read it?

Show answer

Because it must rebuild with the new themeMode whenever the state changes. watch subscribes it to changes; read would grab the value once and never update.

Q4 (intermediate). How does changing themeMode recolor the whole app with no per-widget wiring?

Show answer

MaterialApp rebuilds with the new mode, swapping the active ThemeData. Because widgets read theme roles (inherited theme), they rebuild and re-read the new scheme — the Foundations adaptivity, now driven by a provider.

Q5 (intermediate). Why is ref.read(...notifier) (not watch) correct inside onPressed?

Show answer

In a callback you want to call a method, not subscribe to rebuilds. ref.read(provider.notifier) fetches the notifier once to call toggle()/setMode(). Watching the notifier just to call a method is the Foundation Part 5 anti-pattern.

Q6 (advanced). Compare a NotifierProvider to a setState-plus-callbacks approach for theme mode.

Show answer

With setState the mode lives in one widget's State, so any descendant that wants to change it needs an onThemeChanged callback prop-drilled down. A NotifierProvider makes the mode globally accessible: any widget reads it with ref.watch and mutates it with ref.read(...notifier), no prop-drilling, with automatic, scoped rebuilds. It also sets up cleanly for persistence (Part 2) and testing.


Wrapping up

  • We turned the frozen themeMode: ThemeMode.system into state held by a NotifierProvider<ThemeMode>.
  • MaterialApp (ConsumerWidget) ref.watches the provider for themeMode; the app is wrapped in ProviderScope.
  • Any widget changes the theme via ref.read(themeModeProvider.notifier).setMode/toggle — even deep in a settings page, no callbacks.
  • This decoupling is exactly what Riverpod is for; setState/globals/raw InheritedWidget all fall short.

The one flaw: the choice forgets itself on restart. In Part 2 we fix that — persisting the theme preference with SharedPreferences + Riverpod.