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/NotifierProviderare 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 (
ThemeModestate) 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()returnsThemeMode.system, the respectful default from Foundations Part 5.setModelets a settings screen pick any of the three modes.toggleis the quick "moon/sun button" action.- The provider exposes the notifier and its
ThemeModestate, created with theThemeModeNotifier.newtear-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.watchhere?MaterialAppmust 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
Brightnessproperly. - 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.systeminto state held by aNotifierProvider<ThemeMode>. MaterialApp(ConsumerWidget)ref.watches the provider forthemeMode; the app is wrapped inProviderScope.- 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/rawInheritedWidgetall fall short.
The one flaw: the choice forgets itself on restart. In Part 2 we fix that — persisting the theme preference with SharedPreferences + Riverpod.