← Back to blog
Mastering Riverpod: Theming · Part 2 of 6
August 31, 20269 min read

Persisting Theme Preference with SharedPreferences + Riverpod

RiverpodFlutterDart

Persisting Theme Preference with SharedPreferences

This is Part 2 of Mastering Riverpod: Theming. In Part 1 we made ThemeMode into reactive state — but it has amnesia. Pick Dark, kill the app, relaunch… and you're back to System. This part gives the choice a memory.

The tool is SharedPreferences — a tiny key-value store for simple settings — wired into our notifier so every change is saved and the saved value is restored on launch. Along the way you'll meet the pattern for any persisted Riverpod state.

Analogy — the sticky note on the fridge. SharedPreferences is the sticky note where you jot "we're out of milk." It's perfect for small facts (a theme choice, a flag, a username), terrible for a spreadsheet (use a database for that). We write the note when the user changes the theme and read it when the app opens.


What SharedPreferences is (and isn't)

SharedPreferences persists primitive key-value pairs (bool, int, double, String, List<String>) to disk, backed by the platform's native store. It's asynchronous to open and ideal for small settings.

# pubspec.yaml
dependencies:
  shared_preferences: ^2.3.0
import 'package:shared_preferences/shared_preferences.dart';

final prefs = await SharedPreferences.getInstance(); // async — note the await
await prefs.setString('themeMode', 'dark');           // write
final raw = prefs.getString('themeMode');             // read → 'dark' or null

Don't store large or structured data here (use a database/files). Do store a single setting like our theme mode. Keep keys in one place to avoid typos.


The plan

Three moving parts:

  1. A tiny repository that knows how to read/write the ThemeMode to SharedPreferences (a clean seam we can fake in tests).
  2. An async-initialized notifier that loads the saved value on startup and saves on every change.
  3. A MaterialApp that handles the brief "still loading" moment gracefully.

Because reading prefs is async, our notifier becomes an AsyncNotifier — its build() awaits the stored value. This is exactly the "if build() must await, use AsyncNotifier" rule from Provider Types Part 5.


Step 1 — a repository for the preference

Keep the storage details behind a small class. This is the dependency seam that makes testing trivial later.

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

class ThemeRepository {
  ThemeRepository(this._prefs);
  final SharedPreferences _prefs;
  static const _key = 'themeMode';

  ThemeMode read() {
    switch (_prefs.getString(_key)) {
      case 'light': return ThemeMode.light;
      case 'dark':  return ThemeMode.dark;
      default:      return ThemeMode.system; // null / unknown → default
    }
  }

  Future<void> write(ThemeMode mode) =>
      _prefs.setString(_key, mode.name); // 'light' | 'dark' | 'system'
}

mode.name gives the enum's name as a String (ThemeMode.dark.name == 'dark'), and read() maps it back, defaulting to system for a missing/garbage value.

Expose dependencies as providers

We provide the SharedPreferences instance and the repository through Riverpod. The prefs provider is overridden at startup with the real instance (see Step 3):

import 'package:flutter_riverpod/flutter_riverpod.dart';

// Overridden in main() with the real instance.
final sharedPrefsProvider = Provider<SharedPreferences>(
  (ref) => throw UnimplementedError('Override in ProviderScope'),
);

final themeRepositoryProvider = Provider<ThemeRepository>(
  (ref) => ThemeRepository(ref.watch(sharedPrefsProvider)),
);

The "throw until overridden" trick is the standard Riverpod way to inject something you only have after main() runs — overriding it in ProviderScope is Foundation Part 3 territory.


Step 2 — an AsyncNotifier that loads and saves

Now the notifier loads the saved mode in build() and persists on every change.

class ThemeModeNotifier extends AsyncNotifier<ThemeMode> {
  @override
  Future<ThemeMode> build() async {
    // Read the saved value (sync read; prefs already loaded — see Step 3).
    return ref.watch(themeRepositoryProvider).read();
  }

  Future<void> setMode(ThemeMode mode) async {
    // Optimistically reflect the new value in the UI…
    state = AsyncData(mode);
    // …then persist it.
    await ref.read(themeRepositoryProvider).write(mode);
  }

  Future<void> toggle() async {
    final current = state.valueOrNull ?? ThemeMode.system;
    await setMode(current == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark);
  }
}

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

Key points:

  • The state is now an AsyncValue<ThemeMode> — loading, data, or error.
  • setMode sets state = AsyncData(mode) first so the UI flips instantly, then writes to disk. (If the write could fail in a way you must surface, wrap it and set an error state.)
  • toggle reads the current value via state.valueOrNull and delegates to setMode.

Why optimistic? Disk writes take a few milliseconds. Updating state before awaiting the write keeps the toggle feeling instant; the persisted value catches up a moment later. The user never waits on the filesystem to see their theme change.


Step 3 — initialize prefs and override the provider

SharedPreferences.getInstance() is async, so we await it once in main() and inject it. That way the repository's read() is a fast synchronous call (the box is already open).

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();          // required before async in main
  final prefs = await SharedPreferences.getInstance(); // load once

  runApp(
    ProviderScope(
      overrides: [
        sharedPrefsProvider.overrideWithValue(prefs), // inject the real instance
      ],
      child: const App(),
    ),
  );
}

This is the cleanest pattern: do the one unavoidable async load up front, then everything downstream is synchronous and testable.


Step 4 — handle the (tiny) loading state in MaterialApp

Because themeModeProvider is now async, the root reads an AsyncValue. The load is near-instant (prefs are pre-loaded), but handle it properly anyway:

class App extends ConsumerWidget {
  const App({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final modeAsync = ref.watch(themeModeProvider);

    // Until loaded, fall back to system; never block the UI on a setting.
    final mode = modeAsync.valueOrNull ?? ThemeMode.system;

    return MaterialApp(
      theme: themeFor(Brightness.light),
      darkTheme: themeFor(Brightness.dark),
      themeMode: mode,
      home: const HomePage(),
    );
  }
}

Design choice: for a theme, don't show a spinner while the preference loads — fall back to system and let it snap to the saved value when ready (usually the same frame, since prefs were pre-loaded in main). valueOrNull ?? ThemeMode.system does exactly that. A blocking modeAsync.when(loading: ...) splash is overkill for one setting.

The UI from Part 1 (toggle button, segmented selector) works unchanged except the calls are now async:

onPressed: () => ref.read(themeModeProvider.notifier).toggle(),

A note on Riverpod 3's built-in persistence

Riverpod 3.0 ships an offline persistence feature: Notifier-family providers can cache their state locally and restore it automatically, reducing the boilerplate above. It's powerful, but doing it manually once (as we did) teaches you exactly what's happening — load on build, save on change, inject the store. Once you understand this flow, adopting the built-in persistence is a small step, and you'll know what it's doing under the hood.

Takeaway: the pattern — async load in build(), persist in your mutator, inject the store via a provider — is universal. It works for theme mode, locale, onboarding flags, auth tokens, and anything else you keep in SharedPreferences.


Practice Challenges

Challenge 1 — Repository. Write ThemeRepository.read()/write() mapping ThemeMode to/from a string in SharedPreferences.

Show solution
ThemeMode read() => switch (_prefs.getString('themeMode')) {
  'light' => ThemeMode.light,
  'dark' => ThemeMode.dark,
  _ => ThemeMode.system,
};
Future<void> write(ThemeMode m) => _prefs.setString('themeMode', m.name);

Challenge 2 — Async build. Why is the notifier an AsyncNotifier now, not a Notifier?

Show solution

Initialization depends on reading from storage. Even though our read() is synchronous after pre-loading, the persistence flow is inherently async (getInstance() is async), and modeling state as AsyncValue lets the UI handle loading/error cleanly. Per the Provider Types Part 5 rule, anything whose initialization can await belongs in an AsyncNotifier.

Challenge 3 — Optimistic update. In setMode, why set state = AsyncData(mode) before awaiting the write?

Show solution

So the UI reflects the new theme immediately instead of waiting for the disk write (a few ms). The write then persists the value; the user never perceives latency.

Challenge 4 — Inject prefs. Show how to provide the real SharedPreferences to a provider that throws until overridden.

Show solution
final prefs = await SharedPreferences.getInstance();
ProviderScope(
  overrides: [sharedPrefsProvider.overrideWithValue(prefs)],
  child: const App(),
);

Challenge 5 — No spinner. Justify falling back to ThemeMode.system instead of showing a loading screen while the preference loads.

Show solution

A theme is non-blocking: rendering with system and snapping to the saved value (typically the same frame, since prefs are pre-loaded in main) is smoother than gating the whole app behind a spinner for a single boolean-ish setting. valueOrNull ?? ThemeMode.system expresses that.


Questions to test yourself

Q1 (basic). What is SharedPreferences good for, and what should you not use it for?

Show answer

Good for small primitive key-value settings (a theme choice, flags, a username). Not for large or structured data — use a database/files for that.

Q2 (basic). How do you serialize a ThemeMode to a string and back?

Show answer

Write mode.name ('light'/'dark'/'system'); read the string and map it back to the enum, defaulting to ThemeMode.system for null/unknown.

Q3 (intermediate). Why pre-load SharedPreferences in main() and inject it, rather than calling getInstance() inside the notifier?

Show answer

Doing the single unavoidable async load once in main() lets every downstream read be synchronous and keeps the store injectable (overridable in ProviderScope), which makes the repository easy to fake in tests and avoids repeated async calls.

Q4 (intermediate). What type is the state now, and how does the root widget consume it without a spinner?

Show answer

AsyncValue<ThemeMode>. The root reads modeAsync.valueOrNull ?? ThemeMode.system, falling back to system until the (near-instant) load completes — no blocking loading UI.

Q5 (intermediate). Why route persistence through a ThemeRepository instead of calling prefs directly in the notifier?

Show answer

It isolates storage behind a seam: the notifier depends on an abstraction it can't see the internals of, so you can swap SharedPreferences for another store and inject a fake repository in tests without touching notifier logic.

Q6 (advanced). Sketch how you'd handle a write that fails (e.g. storage error) while keeping the UI responsive.

Show answer

Keep the optimistic state = AsyncData(mode) so the UI flips immediately, but wrap the await write(mode) in a try/catch. On failure, either surface a transient error (snackbar) while leaving the in-memory state, or revert to the previous value and set state = AsyncError(e, st) so listeners can react. The choice depends on whether a failed persist should visibly undo the change; for a theme, a non-fatal snackbar while keeping the new look is usually best.


Wrapping up

  • SharedPreferences persists small settings; we store the ThemeMode as a string (mode.name).
  • A ThemeRepository hides storage behind a clean, testable seam; prefs are injected via an overridden Provider.
  • The notifier becomes an AsyncNotifier: build() loads the saved value, mutators optimistically set state then write to disk.
  • The root falls back to ThemeMode.system until the near-instant load finishes — no spinner for one setting.
  • This load-on-build / save-on-change / inject-the-store pattern generalizes to locale, flags, tokens, and more (and Riverpod 3's built-in persistence automates it).

In Part 3 we zoom into the live switch itself — dynamic theme switching at runtime with no app restart — including the subtle rebuild and animation details.