shared_preferences_riverpod_new

One line per persisted preference, on Riverpod 3.

final darkModeProvider = syncPrefProvider<bool>('settings.dark_mode', false);

That's the whole declaration. Read it, write it:

final isDark = ref.watch(darkModeProvider);      // T, no AsyncValue
ref.read(darkModeProvider.notifier).set(true);   // write

Register the instance once, before runApp:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  registerSyncPrefs(await SharedPreferences.getInstance());
  runApp(const MyApp());
}

This is a rewrite of shared_preferences_riverpod, which stopped at Riverpod 2. Same idea, rebuilt on AsyncNotifier, no code generation, no hooks_riverpod dependency.

Why

Hand-written, every preference is the same fifteen lines: getInstance() in build, then compare / AsyncData / getInstance() / setX in the setter — differing only in key, default and type. Five preferences, five copies. This collapses them into one generic notifier, and call sites read exactly like any other AsyncNotifier.

Install

dependencies:
  shared_preferences_riverpod_new: ^0.2.0

sync or async

syncPrefProvider is the one you want when the app awaits SharedPreferences.getInstance() before runApp — the values are already in memory by the time any widget builds, so there is nothing to wait for.

prefProvider is the async form, state is AsyncValue<T>. Reach for it only when a preference can genuinely become ready mid-session.

Picking async when you did not need it costs correctness, not just noise. During AsyncLoading the idiomatic read

ref.watch(p).value ?? defaultValue

hands back the default — not null, but a perfectly plausible wrong value that the caller cannot tell apart from a real one. A one-shot ref.read(p).value ?? defaultValue in a callback, on a screen that never watches p, gets the default every time. syncPrefProvider reads once, synchronously, and has exactly one state, so neither failure exists.

Supported types

Whatever SharedPreferences can store: bool, int, double, String, List<String>. Anything else throws ArgumentError on write.

A value on disk whose type no longer matches (an older release stored a String where an int now lives) is treated as absent — you get the default back instead of a crash.

normalize: guard both ends

normalize runs on the value read from disk and on every value written. Junk on disk can't get in, and an out-of-range new value can't get out:

final fontSizeProvider = prefProvider<int>(
  'settings.font_size',
  14,
  normalize: (v) => v.clamp(10, 24),
);

Same value, no write

set compares against the current state first. Writing the value that's already there does nothing: no disk write, no notification.

Preferences with behaviour

When the rule belongs to the preference rather than to its callers, subclass PrefNotifier and wire it to an AsyncNotifierProvider yourself:

class HintShows extends PrefNotifier<int> {
  HintShows() : super('hint.shows', 0);

  static const int maxShows = 5;

  /// Whether the hint may be shown once more. Returns true after the bumped
  /// count has been persisted.
  Future<bool> tryConsume() async {
    final current = state.value ?? await future;
    if (current >= maxShows) return false;
    await set(current + 1);
    return true;
  }
}

final hintShowsProvider =
    AsyncNotifierProvider<HintShows, int>(HintShows.new, name: 'hintShows');

Bounds and defaults live with the preference the same way, instead of being restated at every call site:

class AutoLockMinutes extends PrefNotifier<int> {
  AutoLockMinutes() : super('settings.auto_lock', fallback, normalize: _clamp);

  static const int min = 1;
  static const int max = 60;
  static const int fallback = 5;

  static int _clamp(int minutes) => minutes.clamp(min, max);
}

Injecting the store

By default every notifier calls SharedPreferences.getInstance(). Pass prefs to point it somewhere else — a pre-warmed instance, a scoped store, a fake:

final p = prefProvider<int>('t.count', 0, prefs: () async => myPrefs);

It's a function, not an instance, so the provider stays lazy. There is no global singleton to set up or tear down.

Testing

SharedPreferences.setMockInitialValues works as usual:

setUp(() => SharedPreferences.setMockInitialValues({}));

test('cold start reads the stored value back', () async {
  SharedPreferences.setMockInitialValues({'t.count': 7});
  final p = prefProvider<int>('t.count', 0);
  final container = ProviderContainer();
  addTearDown(container.dispose);
  expect(await container.read(p.future), 7);
});

Notes

  • Providers are kept alive, not auto-disposed. A user preference has no business being forgotten because nothing is watching it this second.
  • No code generation. prefProvider is a generic factory, and @Riverpod can't generate a generic notifier.
  • The package depends on riverpod, not hooks_riverpod — bring your own binding layer (flutter_riverpod, hooks_riverpod, whichever).

License

MIT

Libraries

shared_preferences_riverpod_new
One-liner Riverpod 3 providers backed by SharedPreferences.