You don’t need a database to save a user’s dark mode preference, their username, or whether they’ve seen your onboarding screen. SharedPreferences handles all of this in under 10 lines of code — no SQL, no schema, no boilerplate.
But most tutorials only show the basic get/set snippet and leave out the things that actually trip up beginners: loading preferences correctly in initState, the three APIs that now exist (including two that most tutorials don’t cover), why your Switch widget snaps back after being tapped, how to clear data safely on logout without deleting app settings, and when SharedPreferences is no longer the right tool.
This post covers all of it.
- Dart async/await and Future basics — SharedPreferences uses async throughout
- StatefulWidget and initState — where you load stored preferences
- Know how to add packages to pubspec.yaml
- Optional: read the Local Storage Comparison post first to confirm SharedPreferences is the right tool for your use case
1. What SharedPreferences Is (and Isn’t)
SharedPreferences is a Flutter plugin that wraps each platform’s native key-value storage. It is not a database — it is a thin layer over whatever the operating system already provides for storing lightweight settings:
| Platform | What it actually uses |
|---|---|
| Android (new API) | Jetpack DataStore Preferences (recommended by Google) |
| Android (legacy API) | Android SharedPreferences XML file |
| iOS / macOS | NSUserDefaults |
| Web | localStorage |
| Windows | Roaming AppData directory file |
| Linux | XDG_DATA_HOME directory file |
Understanding this explains SharedPreferences’ hard limits — it can only store 5 primitive types, has no query or filter capability, and has no write-persistence guarantee on all platforms. It is designed for settings, not data.
| ✅ Use SharedPreferences for | ❌ Do NOT use SharedPreferences for |
|---|---|
| Dark/light mode preference | Auth tokens or passwords |
| Onboarding “seen” flag | Custom Dart objects (use Hive) |
| Selected language code | Lists of objects (use Hive) |
| Login state boolean | Any data you need to query/filter |
| Simple counters or scores | Relational data (use sqflite) |
| Last-used search query | Critical financial or health data |
2. Setup and Installation
# pubspec.yaml
dependencies:
shared_preferences: ^2.5.4 # check pub.dev for the latest version
# Then run:
flutter pub get
// Import in your Dart file
import 'package:shared_preferences/shared_preferences.dart';
If you load SharedPreferences before
runApp() — for example to pass initial preferences to your app — you must call WidgetsFlutterBinding.ensureInitialized() first. Skipping it causes a crash. This is one of the most common beginner errors:
void main() async { WidgetsFlutterBinding.ensureInitialized(); // ← always first final prefs = await SharedPreferences.getInstance(); runApp(MyApp(prefs: prefs));}
3. The Three APIs — Which One to Use
As of v2.3.0+ there are three distinct SharedPreferences APIs. Most online tutorials only show the legacy one, which is being deprecated. Here is what all three are and when to use each:
| API | Reads | Status | Use when |
|---|---|---|---|
SharedPreferences | Synchronous (cached) | ⚠️ Being deprecated | Existing codebases only — avoid for new projects |
SharedPreferencesAsync | Async (no cache) | ✅ New | Firebase Messaging, background isolates, or when you always need fresh data |
SharedPreferencesWithCache | Synchronous (allowList cache) | ✅ Recommended default | Most new projects — fast reads + scoped cache |
// ── API 1: Legacy SharedPreferences ───────────────────────────────────────
// ⚠️ Avoid for new projects. Being deprecated.
// Reads are synchronous after getInstance() — uses an in-memory cache.
// Bug: if Firebase Messaging background isolate writes to prefs,
// the UI isolate reads stale cached data.
final SharedPreferences legacyPrefs = await SharedPreferences.getInstance();
final bool isDark = legacyPrefs.getBool('isDarkMode') ?? false; // sync read
await legacyPrefs.setBool('isDarkMode', true); // async write
// ─────────────────────────────────────────────────────────────────────────
// ── API 2: SharedPreferencesAsync ─────────────────────────────────────────
// ✅ New async API. No cache — every read goes to platform storage.
// Always fresh data. Use when Firebase or background code shares the store.
const asyncPrefs = SharedPreferencesAsync(); // no await to create
final bool? isDarkAsync = await asyncPrefs.getBool('isDarkMode'); // async read
await asyncPrefs.setBool('isDarkMode', true);
// Scoped clear — only clears the specified keys:
await asyncPrefs.clear(allowList: {'isDarkMode', 'username'});
// ─────────────────────────────────────────────────────────────────────────
// ── API 3: SharedPreferencesWithCache (Recommended default) ───────────────
// ✅ Best of both — sync reads from cache, async writes to platform storage.
// The allowList scopes which keys are cached (any key not in allowList
// cannot be read or written through this instance).
final prefsWithCache = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
allowList: {'isDarkMode', 'username', 'hasSeenOnboarding', 'language'},
),
);
// Reads are synchronous — no await needed
final bool isDarkCached = prefsWithCache.getBool('isDarkMode') ?? false;
final String username = prefsWithCache.getString('username') ?? 'Guest';
final bool onboarded = prefsWithCache.getBool('hasSeenOnboarding') ?? false;
// Writes are still async
await prefsWithCache.setBool('isDarkMode', true);
await prefsWithCache.setString('username', 'Alice');
4. Core Operations: Read, Write, Remove, Clear
SharedPreferences supports exactly 5 primitive types. Here is the complete API for all of them across both the legacy and modern approaches:
// Using SharedPreferencesAsync (recommended for new projects)
const prefs = SharedPreferencesAsync();
// ── WRITE: all setters return Future<void> ────────────────────────────────
await prefs.setInt('loginCount', 5);
await prefs.setDouble('textScale', 1.25);
await prefs.setBool('isDarkMode', true);
await prefs.setString('username', 'Alice');
await prefs.setStringList('recentSearches', ['Flutter', 'Dart', 'Firebase']);
// ── READ: all getters return nullable T? ──────────────────────────────────
final int? count = await prefs.getInt('loginCount');
final double? scale = await prefs.getDouble('textScale');
final bool? isDark = await prefs.getBool('isDarkMode');
final String? name = await prefs.getString('username');
final List<String>? searches = await prefs.getStringList('recentSearches');
// ── Always use ?? for safe defaults ──────────────────────────────────────
// If a key has never been set, the getter returns null.
// Never force-unwrap with ! — use ?? with a sensible default instead:
final int loginCount = await prefs.getInt('loginCount') ?? 0;
final double textScale = await prefs.getDouble('textScale') ?? 1.0;
final bool isDarkMode = await prefs.getBool('isDarkMode') ?? false;
final String displayName = await prefs.getString('username') ?? 'Guest';
final List<String> history = await prefs.getStringList('recentSearches') ?? [];
// ── CHECK if a key exists ─────────────────────────────────────────────────
final bool usernameExists = await prefs.containsKey('username');
if (!usernameExists) {
print('First time user — no username saved yet');
}
// ── REMOVE a single key ───────────────────────────────────────────────────
await prefs.remove('username'); // only this key is deleted
// ── CLEAR: two approaches ─────────────────────────────────────────────────
// 1. Clear ALL keys (dangerous — see logout section for why)
await prefs.clear();
// 2. Clear only specific keys (safer)
await prefs.clear(allowList: {'username', 'lastEmail', 'authToken'});
// ↑ Only these three keys are deleted; everything else survives
5. Async Loading in initState — Three Correct Patterns
initState() is not async — you cannot use await directly inside it. But SharedPreferences operations all return Futures. This mismatch is the most-Googled SharedPreferences question on Stack Overflow. Here are all three correct solutions:
❌ The wrong way (compile error)
@override
void initState() {
super.initState();
// ❌ ERROR: initState is not async — you cannot await here
final prefs = await SharedPreferences.getInstance();
_isDarkMode = prefs.getBool('isDarkMode') ?? false;
}
✅ Pattern 1 — Async helper method (simplest, most common)
class _SettingsPageState extends State<SettingsPage> {
bool _isDarkMode = false;
String _language = 'en';
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadPreferences(); // ← call without await — "fire and forget"
// initState returns immediately; _loadPreferences runs asynchronously
}
// The async work lives in a separate method
Future<void> _loadPreferences() async {
const prefs = SharedPreferencesAsync();
final isDark = await prefs.getBool('isDarkMode') ?? false;
final lang = await prefs.getString('language') ?? 'en';
// setState triggers a rebuild once data is ready
setState(() {
_isDarkMode = isDark;
_language = lang;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
body: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
value: _isDarkMode, // ← reads local state, NOT prefs directly
onChanged: (val) async {
setState(() => _isDarkMode = val); // update UI immediately
const prefs = SharedPreferencesAsync();
await prefs.setBool('isDarkMode', val); // persist in background
},
),
],
),
);
}
}
✅ Pattern 2 — FutureBuilder (when loading state drives the whole UI)
class _SettingsPageState extends State<SettingsPage> {
// ⚠️ Critical: store the Future in a field, not inline in FutureBuilder
// If you write FutureBuilder(future: SharedPreferences.getInstance(), ...)
// it re-runs the Future on every rebuild (causes the Switch snapping bug)
late final Future<SharedPreferences> _prefsFuture;
@override
void initState() {
super.initState();
_prefsFuture = SharedPreferences.getInstance(); // stored once
}
@override
Widget build(BuildContext context) {
return FutureBuilder<SharedPreferences>(
future: _prefsFuture, // ← reference to stored future
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final prefs = snapshot.data!;
final isDark = prefs.getBool('isDarkMode') ?? false;
return Scaffold(
body: SwitchListTile(
title: const Text('Dark Mode'),
value: isDark,
onChanged: (val) async {
await prefs.setBool('isDarkMode', val);
setState(() {}); // trigger rebuild so FutureBuilder re-reads
},
),
);
},
);
}
}
✅ Pattern 3 — Pre-load in main() (cleanest for production)
// Load preferences BEFORE runApp — pass them down as constructor arguments.
// No initState loading, no FutureBuilder. Cleanest production pattern.
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // ← required before any async
final prefs = await SharedPreferences.getInstance();
final isDarkMode = prefs.getBool('isDarkMode') ?? false;
final hasOnboarded = prefs.getBool('hasSeenOnboarding') ?? false;
runApp(MyApp(
isDarkMode: isDarkMode,
hasOnboarded: hasOnboarded,
prefs: prefs,
));
}
class MyApp extends StatelessWidget {
const MyApp({
super.key,
required this.isDarkMode,
required this.hasOnboarded,
required this.prefs,
});
final bool isDarkMode;
final bool hasOnboarded;
final SharedPreferences prefs;
@override
Widget build(BuildContext context) {
return MaterialApp(
themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light,
home: hasOnboarded ? const HomeScreen() : const OnboardingScreen(),
);
}
}
Pattern 1 (async helper) — use for most screens. Simple, readable, handles loading state per widget.
Pattern 2 (FutureBuilder) — use when the entire screen layout depends on loaded preferences.
Pattern 3 (pre-load in main) — use for app-level settings that drive navigation or theming. Eliminates loading states entirely.
6. Key Naming Conventions and the PrefKeys Pattern
Every SharedPreferences entry is stored under a string key. Typos in keys are silent bugs — getString('username') vs getString('user_name') both compile fine but one returns null. This section is missing from most tutorials and causes real production bugs.
// ❌ Anti-pattern: scattered string literals across files
// settings_screen.dart:
await prefs.setBool('darkMode', value); // writes 'darkMode'
// home_screen.dart:
final isDark = prefs.getBool('dark_mode'); // reads 'dark_mode' — null!
// Different key, silent bug, no compile-time error
// ─────────────────────────────────────────────────────────────────────────
// ✅ Pattern 1: Centralized constants class
// Define all keys in one place — autocomplete catches typos at call sites
class PrefKeys {
PrefKeys._(); // private constructor — prevents instantiation
// App settings (survive logout)
static const String isDarkMode = 'isDarkMode';
static const String language = 'language';
static const String textScaleFactor = 'textScaleFactor';
static const String notificationsEnabled = 'notificationsEnabled';
// Onboarding (survive logout, set once)
static const String hasSeenOnboarding = 'hasSeenOnboarding';
static const String appVersion = 'appVersion';
// User session (cleared on logout)
static const String lastEmail = 'lastEmail';
static const String isLoggedIn = 'isLoggedIn';
static const String userId = 'userId';
// All user-session keys in a Set — used for targeted logout clearing
static const Set<String> userSessionKeys = {
lastEmail,
isLoggedIn,
userId,
};
}
// Usage — autocomplete works, typos cause compile errors:
await prefs.setBool(PrefKeys.isDarkMode, true);
final bool isDark = await prefs.getBool(PrefKeys.isDarkMode) ?? false;
Use
camelCase to match Dart conventions (isDarkMode, not is_dark_mode). Use descriptive names, not abbreviations (hasSeenOnboarding, not hso). For SharedPreferencesWithCache, the allowList must match key strings exactly — a mismatch silently prevents access. Flutter’s legacy API also internally prepends flutter. to all keys (so your 'isDarkMode' is stored as 'flutter.isDarkMode' on disk) — you never see this in code but it matters when reading from native Android tools.
7. The AppPrefs Service Wrapper (Production Pattern)
For real apps, the cleanest pattern is a wrapper service that hides the SharedPreferences instance, centralises all keys, and exposes typed getters and setters. This eliminates magic strings entirely, makes unit testing trivial, and ensures defaults are always consistent:
import 'package:shared_preferences/shared_preferences.dart';
/// Single source of truth for all app preferences.
/// All keys, types, and defaults defined in one place.
class AppPrefs {
AppPrefs._(this._prefs);
final SharedPreferences _prefs;
/// Factory constructor — loads and returns an AppPrefs instance
static Future<AppPrefs> load() async {
final prefs = await SharedPreferences.getInstance();
return AppPrefs._(prefs);
}
// ── Private key constants ─────────────────────────────────────────────
static const _isDarkMode = 'isDarkMode';
static const _language = 'language';
static const _textScaleFactor = 'textScaleFactor';
static const _hasSeenOnboarding = 'hasSeenOnboarding';
static const _lastEmail = 'lastEmail';
static const _isLoggedIn = 'isLoggedIn';
static const _notificationsEnabled = 'notificationsEnabled';
static const _loginCount = 'loginCount';
// ── Typed getters and setters ─────────────────────────────────────────
// Dark mode
bool get isDarkMode => _prefs.getBool(_isDarkMode) ?? false;
Future<void> setDarkMode(bool val) => _prefs.setBool(_isDarkMode, val);
// Language
String get language => _prefs.getString(_language) ?? 'en';
Future<void> setLanguage(String val) => _prefs.setString(_language, val);
// Text scale
double get textScaleFactor => _prefs.getDouble(_textScaleFactor) ?? 1.0;
Future<void> setTextScaleFactor(double val) =>
_prefs.setDouble(_textScaleFactor, val);
// Onboarding
bool get hasSeenOnboarding => _prefs.getBool(_hasSeenOnboarding) ?? false;
Future<void> setHasSeenOnboarding(bool val) =>
_prefs.setBool(_hasSeenOnboarding, val);
// Auth / session
String get lastEmail => _prefs.getString(_lastEmail) ?? '';
Future<void> setLastEmail(String val) => _prefs.setString(_lastEmail, val);
bool get isLoggedIn => _prefs.getBool(_isLoggedIn) ?? false;
Future<void> setIsLoggedIn(bool val) => _prefs.setBool(_isLoggedIn, val);
// Notifications
bool get notificationsEnabled =>
_prefs.getBool(_notificationsEnabled) ?? true;
Future<void> setNotificationsEnabled(bool val) =>
_prefs.setBool(_notificationsEnabled, val);
// Login counter
int get loginCount => _prefs.getInt(_loginCount) ?? 0;
Future<void> incrementLoginCount() =>
_prefs.setInt(_loginCount, loginCount + 1);
// ── Logout: clear only user-session keys ──────────────────────────────
// App settings like isDarkMode and language survive logout
Future<void> logout() async {
await _prefs.remove(_lastEmail);
await _prefs.remove(_isLoggedIn);
// isDarkMode and language intentionally NOT removed
}
// ── Full reset: clear everything (used for "Reset App" in settings) ───
Future<void> resetAll() => _prefs.clear();
}
// ── Usage ─────────────────────────────────────────────────────────────────
// In main.dart:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final appPrefs = await AppPrefs.load();
runApp(MyApp(prefs: appPrefs));
}
// In any screen — typed access, no magic strings:
class _SettingsPageState extends State<SettingsPage> {
late AppPrefs _prefs;
@override
void initState() {
super.initState();
_loadPrefs();
}
Future<void> _loadPrefs() async {
final prefs = await AppPrefs.load();
setState(() => _prefs = prefs);
}
void _handleDarkModeToggle(bool val) async {
setState(() {}); // optimistic UI update
await _prefs.setDarkMode(val); // persists to disk
}
}
8. The Switch Snapping Bug — Root Cause and Fix
This is the most viral SharedPreferences bug on Stack Overflow. You add a Switch or SwitchListTile that toggles dark mode. When you tap it, it briefly flips to the new position — then snaps straight back to where it was. Infuriating and not obvious why it happens.
The root cause
// ❌ The buggy version — causes the snap-back
class _BuggySettingsState extends State<BuggySettings> {
@override
Widget build(BuildContext context) {
return FutureBuilder<SharedPreferences>(
future: SharedPreferences.getInstance(), // ← new Future on every build!
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final prefs = snapshot.data!;
return Switch(
value: prefs.getBool('isDarkMode') ?? false, // ← reads from prefs directly
onChanged: (val) async {
await prefs.setBool('isDarkMode', val); // async write
setState(() {}); // triggers rebuild
// ↑ On rebuild: FutureBuilder creates a NEW future, re-reads prefs,
// briefly gets the OLD cached value → Switch snaps to old position
// then snaps again to new position when cache updates → visual flicker
},
);
},
);
}
}
// The chain of events causing the snap:
// 1. User taps Switch → shows new position (optimistic)
// 2. onChanged fires: starts async setBool
// 3. setState() called → build() runs → new FutureBuilder future created
// 4. While new future resolves: prefs still has OLD cached value
// 5. Switch renders with OLD value → snaps back visually
// 6. Future resolves with NEW value → Switch snaps forward again
// Total: snap-back then snap-forward = visual glitch
✅ Fix 1 — Local state variable (recommended)
// ✅ The fix: separate the UI source of truth from the persistence layer.
// The Switch reads _isDarkMode (local state), NOT prefs directly.
// Local state updates immediately (synchronous). Prefs persist in background.
class _FixedSettingsState extends State<FixedSettings> {
bool _isDarkMode = false; // ← local state variable: the Switch reads THIS
bool _notifications = true;
String _language = 'en';
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadPreferences();
}
Future<void> _loadPreferences() async {
const prefs = SharedPreferencesAsync();
final isDark = await prefs.getBool('isDarkMode') ?? false;
final notifs = await prefs.getBool('notificationsEnabled') ?? true;
final lang = await prefs.getString('language') ?? 'en';
setState(() {
_isDarkMode = isDark;
_notifications = notifs;
_language = lang;
_isLoading = false;
});
}
Future<void> _setDarkMode(bool val) async {
setState(() => _isDarkMode = val); // 1. Update UI immediately
const prefs = SharedPreferencesAsync();
await prefs.setBool('isDarkMode', val); // 2. Persist in background
// Switch reads _isDarkMode — already correct before persist completes
// No snap-back. No flicker.
}
Future<void> _setNotifications(bool val) async {
setState(() => _notifications = val);
const prefs = SharedPreferencesAsync();
await prefs.setBool('notificationsEnabled', val);
}
@override
Widget build(BuildContext context) {
if (_isLoading) return const Center(child: CircularProgressIndicator());
return Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: const Text('Override system theme'),
value: _isDarkMode, // ← local state, never prefs directly
onChanged: _setDarkMode, // ← updates local state first
secondary: Icon(_isDarkMode ? Icons.nightlight : Icons.wb_sunny),
),
SwitchListTile(
title: const Text('Push Notifications'),
value: _notifications,
onChanged: _setNotifications,
),
],
);
}
}
✅ Fix 2 — Provider/Riverpod (for app-wide theme state)
// For dark mode that needs to affect the entire app (not just one screen),
// use a ChangeNotifier. The Switch listens to the notifier, not prefs directly.
// This completely eliminates the snapping bug AND keeps theme in sync everywhere.
class ThemeNotifier extends ChangeNotifier {
bool _isDarkMode = false;
bool get isDarkMode => _isDarkMode;
ThemeNotifier() {
_load();
}
Future<void> _load() async {
const prefs = SharedPreferencesAsync();
_isDarkMode = await prefs.getBool('isDarkMode') ?? false;
notifyListeners(); // triggers rebuild in all listeners
}
Future<void> toggle(bool val) async {
_isDarkMode = val;
notifyListeners(); // UI updates immediately
const prefs = SharedPreferencesAsync();
await prefs.setBool('isDarkMode', val); // persists in background
}
}
// In main.dart:
// ChangeNotifierProvider(create: (_) => ThemeNotifier(), child: MyApp())
// In settings screen — no snap-back, theme updates app-wide:
// Consumer<ThemeNotifier>(
// builder: (_, notifier, __) => SwitchListTile(
// value: notifier.isDarkMode,
// onChanged: notifier.toggle,
// ),
// )
9. Storing Objects via JSON (and When to Stop)
SharedPreferences can’t store custom Dart objects directly. The workaround is JSON serialisation — convert your object to a Map, encode it as a String, store the string. This works, but has clear limits:
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
// ── Define a model with JSON serialization ─────────────────────────────────
class UserProfile {
const UserProfile({
required this.name,
required this.email,
required this.avatarUrl,
required this.isPremium,
});
final String name;
final String email;
final String avatarUrl;
final bool isPremium;
// Serialize to Map for JSON encoding
Map<String, dynamic> toJson() => {
'name': name,
'email': email,
'avatarUrl': avatarUrl,
'isPremium': isPremium,
};
// Deserialize from Map after JSON decoding
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
name: json['name'] as String,
email: json['email'] as String,
avatarUrl: json['avatarUrl'] as String,
isPremium: json['isPremium'] as bool,
);
}
// ── Save a single object ───────────────────────────────────────────────────
Future<void> saveUserProfile(UserProfile profile) async {
const prefs = SharedPreferencesAsync();
final jsonString = jsonEncode(profile.toJson()); // Map → JSON string
await prefs.setString('userProfile', jsonString);
}
// ── Load a single object ───────────────────────────────────────────────────
Future<UserProfile?> loadUserProfile() async {
const prefs = SharedPreferencesAsync();
final jsonString = await prefs.getString('userProfile');
if (jsonString == null) return null;
final map = jsonDecode(jsonString) as Map<String, dynamic>;
return UserProfile.fromJson(map);
}
// ── Save a list of simple strings (natively supported) ────────────────────
Future<void> saveRecentSearches(List<String> searches) async {
const prefs = SharedPreferencesAsync();
await prefs.setStringList('recentSearches', searches.take(10).toList());
// limit to 10 items — SharedPreferences is not for large datasets
}
// ── Save a list of objects (JSON workaround) ───────────────────────────────
Future<void> saveRecentItems(List<UserProfile> items) async {
const prefs = SharedPreferencesAsync();
final jsonList = items.map((i) => jsonEncode(i.toJson())).toList();
await prefs.setStringList('recentItems', jsonList);
}
Future<List<UserProfile>> loadRecentItems() async {
const prefs = SharedPreferencesAsync();
final jsonList = await prefs.getStringList('recentItems') ?? [];
return jsonList
.map((s) => UserProfile.fromJson(jsonDecode(s) as Map<String, dynamic>))
.toList();
}
Stop using SharedPreferences for objects when any of these are true:
• You’re storing objects with more than 5 fields
• You’re storing a list of objects (not just
List<String>)• You need to filter, sort, or search stored data
• You’re managing more than 2–3 different object types
At that point, migrate to Hive (hive_ce) — it stores typed objects natively with zero serialization boilerplate and far better performance.
10. Clearing Data on Logout
prefs.clear() wipes everything — including dark mode, language, and onboarding flags that the user expects to survive logout. This is a subtle but common bug. Use targeted removal instead:
// ── What survives logout vs what gets cleared ──────────────────────────────
//
// Survives logout (app settings): isDarkMode, language, textScaleFactor,
// hasSeenOnboarding, notificationsEnabled
//
// Cleared on logout (user session): lastEmail, isLoggedIn, userId,
// authToken, cachedUsername
// ── Approach 1: Selective remove (most common) ────────────────────────────
Future<void> onLogout() async {
const prefs = SharedPreferencesAsync();
await prefs.remove('lastEmail');
await prefs.remove('isLoggedIn');
await prefs.remove('userId');
// isDarkMode and language intentionally left intact
}
// ── Approach 2: allowList clear (cleanest with new API) ───────────────────
Future<void> onLogoutWithAllowList() async {
const prefs = SharedPreferencesAsync();
// Only the listed keys are deleted — everything else untouched
await prefs.clear(allowList: {'lastEmail', 'isLoggedIn', 'userId'});
}
// ── Approach 3: Save globals, clear all, re-apply (if legacy API) ─────────
Future<void> onLogoutLegacy() async {
final prefs = await SharedPreferences.getInstance();
// 1. Save settings that must survive
final isDark = prefs.getBool('isDarkMode');
final lang = prefs.getString('language');
// 2. Nuke everything
await prefs.clear();
// 3. Re-apply the saved settings
if (isDark != null) await prefs.setBool('isDarkMode', isDark);
if (lang != null) await prefs.setString('language', lang);
}
// ── Approach 4: AppPrefs service logout method (cleanest) ─────────────────
// If you use the AppPrefs wrapper from Section 7:
// await appPrefs.logout(); // ← handles the right keys automatically
11. Security Pitfalls
All data written to SharedPreferences is readable on rooted Android devices and visible in iOS device backups. Never store: auth tokens, JWT tokens, passwords, PINs, API keys, payment information, or any personally identifiable information (PII). Use
flutter_secure_storage for sensitive values instead.
// ❌ NEVER do this — plaintext, insecure
await prefs.setString('authToken', 'Bearer eyJhbGc...');
await prefs.setString('password', 'hunter2');
await prefs.setString('creditCard', '4111111111111111');
// ✅ Use flutter_secure_storage for sensitive values
// add flutter_secure_storage: ^9.0.0 to pubspec.yaml
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const secureStorage = FlutterSecureStorage();
// Write (encrypted via Keychain/Keystore)
await secureStorage.write(key: 'authToken', value: 'Bearer eyJhbGc...');
// Read
final token = await secureStorage.read(key: 'authToken');
// Delete
await secureStorage.delete(key: 'authToken');
// ── Firebase Messaging multi-isolate bug ──────────────────────────────────
// If you use firebase_messaging with background handlers, the background
// isolate has its own SharedPreferences singleton and cache.
// Writes from the background won't be seen in the UI isolate unless
// you call prefs.reload() — or switch to SharedPreferencesAsync (no cache).
// ❌ Stale reads when Firebase background handler wrote to prefs
final prefs = await SharedPreferences.getInstance();
prefs.reload(); // ← must call this manually to see background writes
// ✅ Better: use SharedPreferencesAsync — always reads from disk, no cache
const asyncPrefs = SharedPreferencesAsync();
final val = await asyncPrefs.getBool('key'); // always fresh, no reload needed
12. Migrating from the Legacy API
If you have an existing app using the legacy SharedPreferences.getInstance() API and want to move to SharedPreferencesAsync, the package ships a migration utility. It is idempotent — safe to call on every app launch:
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// ── Step 1: Run migration (idempotent — safe to call every launch) ─────
final legacyPrefs = await SharedPreferences.getInstance();
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
legacySharedPreferencesInstance: legacyPrefs,
sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
migrationCompletedKey: 'sharedPrefsMigrationDone',
// ↑ The migration writes this key when done so it never runs again
);
// ── Step 2: Now use the new async API everywhere ───────────────────────
const prefs = SharedPreferencesAsync();
final isDark = await prefs.getBool('isDarkMode') ?? false;
runApp(MyApp(isDarkMode: isDark));
}
// After migration, all your existing data is accessible via the new API.
// The old flutter.* key prefix is handled automatically.
// Gradually replace SharedPreferences.getInstance() calls with
// SharedPreferencesAsync() or SharedPreferencesWithCache.create() instances.
13. Common Mistakes
| Mistake | What happens | Fix |
|---|---|---|
Using await directly in initState | Compile error | Extract to async method, call from initState without await |
Reading prefs directly as Switch value | Visual snap-back bug | Use local bool state variable; update state first, persist second |
| Scattering key strings across files | Silent null returns from key typos | Centralise in PrefKeys class or AppPrefs wrapper |
Calling prefs.clear() on logout | Wipes dark mode and app settings | Use selective remove() or allowList clear |
| Storing tokens in SharedPreferences | Security vulnerability — plaintext | Use flutter_secure_storage |
Not calling WidgetsFlutterBinding.ensureInitialized() | Crash before runApp | Add as first line of main() when using async before runApp |
| Legacy API with Firebase Messaging | Stale cached reads in background isolate | Switch to SharedPreferencesAsync (no cache) |
| Storing 5+ JSON objects in SharedPreferences | Unmaintainable data layer | Migrate to Hive (hive_ce) for object storage |
Inline FutureBuilder(future: getInstance()) | New future created on every rebuild | Store the future in a field, assign in initState |
14. Interview Q&A
A: SharedPreferences is a Flutter plugin that wraps each platform’s native key-value storage (NSUserDefaults on iOS, Jetpack DataStore on Android, localStorage on Web). It supports only 5 primitive types: int, double, bool, String, and List<String>. Use it for simple app settings and user preferences — dark mode, language selection, onboarding flags, login state. Do not use it for custom objects, relational data, sensitive credentials, or anything requiring querying or filtering.
A:
initState is not async so you can’t use await directly. The standard pattern is to define a separate Future<void> _loadPreferences() async method, call it from initState without await, and inside that method call setState() once data is loaded. Alternatively, pre-load preferences in main() before runApp() and pass them down as constructor arguments — this eliminates the loading state entirely.
A: This happens when the Switch reads its value directly from SharedPreferences (
prefs.getBool('isDarkMode')). When you tap it, the async setBool hasn’t completed yet when Flutter rebuilds — so the Switch re-reads the old cached value and snaps back. The fix is to maintain a local bool _isDarkMode state variable. Call setState(() => _isDarkMode = val) first (synchronous, instant UI update), then persist with await prefs.setBool('isDarkMode', val) in the background. The Switch reads local state — it never snaps.
A: The legacy
SharedPreferences has an in-memory cache and synchronous reads after getInstance() — being deprecated. SharedPreferencesAsync has no cache — every read goes to platform storage (async), always returns fresh data — use with Firebase Messaging or background isolates. SharedPreferencesWithCache wraps Async with a configurable allowList-based cache, giving synchronous reads like the legacy API — recommended for most new projects.
A: Never use
prefs.clear() — it deletes everything including dark mode and language preferences. Instead, use selective removal: call prefs.remove() only for user-session keys (auth token, user ID, email). With the new SharedPreferencesAsync API, use prefs.clear(allowList: {'authToken', 'userId'}) — it only clears the listed keys and leaves everything else intact. The cleanest approach is the AppPrefs service wrapper with a dedicated logout() method that documents exactly which keys are cleared.
15. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Local Storage Comparison | The hub post — confirms whether SharedPreferences is the right tool for your use case before you dive into implementation. |
| Flutter Dark Mode Toggle | The Switch snapping bug in this post appears directly in the dark mode guide — the fix here solves the exact issue described there. |
| Notes App Part 2: Local Persistence | See SharedPreferences used in a real project — the Notes app uses it to persist notes across restarts. |
| Dart Crash Course (Part 0) | Every SharedPreferences operation is async — the Dart crash course covers async/await and Future exactly as needed here. |
| Flutter Widgets: Stateless vs Stateful | Understanding initState and setState is the prerequisite for the async loading patterns in Section 5. |

Pingback: Flutter GoRouter Tutorial: Stop Fighting Navigation and Start Using URLs