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 migrations.
But SharedPreferences is also one of the most misused packages in Flutter. Beginners store sensitive tokens in it (don’t), call it directly inside initState (crashes), read it as a Switch value (causes visual snap-back), and wipe it clean on logout (destroys the user’s settings). This guide covers all of it — the correct patterns, the bugs, the fixes, and exactly when to stop using SharedPreferences and reach for something else.
- Dart async/await basics — SharedPreferences uses async throughout
- StatefulWidget and initState — where you load stored preferences
- Read the local storage comparison post first if you’re not sure SharedPreferences is the right choice
1. What SharedPreferences Is — and Isn’t
SharedPreferences is a Flutter plugin that wraps each platform’s native key-value storage engine. It is not a Flutter invention — it delegates to platform-specific storage that already exists on every device:
| Platform | Native backend |
|---|---|
| Android (legacy API) | Android SharedPreferences |
| Android (new Async/WithCache API) | Jetpack DataStore Preferences (Google’s recommendation) |
| iOS / macOS | NSUserDefaults |
| Web | localStorage |
| Windows | Roaming AppData directory |
| Linux | XDG_DATA_HOME directory |
Because it delegates to platform storage designed for lightweight settings, SharedPreferences has hard constraints you must understand before using it:
// SharedPreferences CAN store:
bool isDarkMode = true;
String username = 'Alice';
int loginCount = 5;
double textScale = 1.2;
List<String> recentSearches = ['flutter', 'dart'];
// SharedPreferences CANNOT store (without workarounds):
// - Custom Dart objects (User, Note, Task...)
// - Maps
// - Nested structures
// - Null values
// SharedPreferences is NOT:
// - A database (no queries, no filtering, no sorting)
// - Secure storage (everything is plaintext)
// - Guaranteed to persist (official docs warn: no guarantee writes
// persist to disk after returning — do not use for critical data)
// - Suitable for large datasets
- Dark mode / theme preference
- Onboarding “has seen” flag
- Selected language code
- Last logged-in email (to pre-fill the login form)
- Simple counters (notification badge count, app open count)
- Font size or display preference
- Sort order preference in a list
2. Installation and Setup
# Option A: add via terminal (recommended)
flutter pub add shared_preferences
# Option B: add manually to pubspec.yaml
# dependencies:
# shared_preferences: ^2.5.4 # check pub.dev for latest
// Import at the top of any Dart file that uses SharedPreferences
import 'package:shared_preferences/shared_preferences.dart';
If you use SharedPreferences before
runApp() (e.g. to pre-load preferences in main()), you must call WidgetsFlutterBinding.ensureInitialized() as the very first line of main(). Without it, Flutter’s platform channels aren’t ready and you’ll get a runtime crash.
// ✅ Correct main.dart when using SharedPreferences before runApp
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // ← FIRST line — always
final prefs = await SharedPreferences.getInstance();
runApp(MyApp(prefs: prefs));
}
// ❌ Wrong — crashes with "Binding has not yet been initialized"
void main() async {
final prefs = await SharedPreferences.getInstance(); // platform not ready yet
runApp(const MyApp());
}
3. The Three APIs: Which One Should You Use?
Since shared_preferences v2.3.0 (Flutter 3.24+), there are three distinct APIs. Most tutorials still only show the legacy one — but the official team has flagged it for deprecation. Here’s what all three are and when to use each:
| API | Cache | Reads | Status | Use when |
|---|---|---|---|---|
SharedPreferences | In-memory | Sync (after getInstance) | ⚠️ Being deprecated | Existing projects — don’t use for new code |
SharedPreferencesAsync | None | Always async | ✅ New, maintained | Firebase Messaging in the stack, background isolates |
SharedPreferencesWithCache | allowList cache | Sync (from cache) | ✅ New, recommended | Most new projects — best of both worlds |
// ── API 1: Legacy SharedPreferences ──────────────────────────────────────
// ⚠️ Still works. Dominant in existing tutorials. Being deprecated.
// Has an in-memory cache — reads are synchronous after getInstance().
// Bug: if a background isolate (Firebase Messaging) also writes to storage,
// the cache goes stale. You need prefs.reload() to refresh.
void legacyExample() async {
final prefs = await SharedPreferences.getInstance(); // one-time async init
// Writes (always async)
await prefs.setBool('isDarkMode', true);
await prefs.setString('username', 'Alice');
// Reads (synchronous — from in-memory cache)
final isDark = prefs.getBool('isDarkMode') ?? false; // no await
final name = prefs.getString('username') ?? 'Guest';
print('$isDark, $name'); // true, Alice
}
// ── API 2: SharedPreferencesAsync ────────────────────────────────────────
// ✅ No cache — every read goes to platform storage.
// Slightly slower per read, but always returns current data.
// Use this if your app has Firebase Messaging, WorkManager, or any
// background plugin that shares the same preferences storage.
void asyncApiExample() async {
const prefs = SharedPreferencesAsync(); // no await needed for construction
// All operations are async
await prefs.setBool('isDarkMode', false);
await prefs.setString('username', 'Bob');
final isDark = await prefs.getBool('isDarkMode') ?? false; // await required
final name = await prefs.getString('username') ?? 'Guest';
print('$isDark, $name'); // false, Bob
// Selective clear — only removes these specific keys
await prefs.clear(allowList: {'isDarkMode', 'username'});
}
// ── API 3: SharedPreferencesWithCache ─────────────────────────────────────
// ✅ RECOMMENDED for most new projects.
// Reads from a scoped in-memory cache (fast, synchronous).
// Writes go to platform storage (async).
// allowList defines which keys are cached — only list the keys you use.
void withCacheExample() async {
final prefs = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
// Only cache these keys — all others require SharedPreferencesAsync
allowList: {'isDarkMode', 'username', 'hasSeenOnboarding', 'language'},
),
);
// Write (async)
await prefs.setBool('isDarkMode', true);
await prefs.setString('username', 'Carol');
await prefs.setBool('hasSeenOnboarding', false);
// Read (synchronous — from cache)
final isDark = prefs.getBool('isDarkMode') ?? false; // no await
final name = prefs.getString('username') ?? 'Guest';
final hasOnboard = prefs.getBool('hasSeenOnboarding') ?? false;
print('Dark: $isDark, User: $name, Onboarded: $hasOnboard');
// Clear (only removes keys in the allowList)
await prefs.clear();
}
For most new Flutter projects: use
SharedPreferencesWithCache. It gives you synchronous reads (like the legacy API), async writes, and a safe scoped cache. Only switch to SharedPreferencesAsync if you have Firebase Messaging or a background isolate that also writes preferences — in that case, you need reads that always hit disk, not a potentially stale cache.
4. All 5 Data Types: Read, Write, Remove, Clear
SharedPreferences supports exactly five primitive types. Here is every operation for all five, using the recommended SharedPreferencesWithCache API:
import 'package:shared_preferences/shared_preferences.dart';
Future<void> fullApiDemo() async {
final prefs = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
allowList: {
'loginCount', 'textScale', 'isDarkMode',
'username', 'recentSearches',
},
),
);
// ── int ───────────────────────────────────────────────────────────────
await prefs.setInt('loginCount', 1);
final int count = prefs.getInt('loginCount') ?? 0;
await prefs.setInt('loginCount', count + 1); // increment
// ── double ────────────────────────────────────────────────────────────
await prefs.setDouble('textScale', 1.5);
final double scale = prefs.getDouble('textScale') ?? 1.0;
// ── bool ──────────────────────────────────────────────────────────────
await prefs.setBool('isDarkMode', true);
final bool isDark = prefs.getBool('isDarkMode') ?? false;
// ── String ────────────────────────────────────────────────────────────
await prefs.setString('username', 'Alice');
final String name = prefs.getString('username') ?? 'Guest';
// ── List<String> ──────────────────────────────────────────────────────
await prefs.setStringList('recentSearches', ['flutter', 'dart', 'hive']);
final List<String> searches = prefs.getStringList('recentSearches') ?? [];
print('Count: $count, Scale: $scale, Dark: $isDark, User: $name');
print('Recent: $searches');
// ── Check if a key exists ─────────────────────────────────────────────
final bool hasUsername = prefs.containsKey('username');
print('Has username: $hasUsername'); // true
// ── Remove a single key ───────────────────────────────────────────────
await prefs.remove('username');
final String? afterRemove = prefs.getString('username');
print('After remove: $afterRemove'); // null
// ── Clear all keys in the allowList ───────────────────────────────────
await prefs.clear(); // only removes keys in the allowList
// ── null safety: always use ?? for defaults ───────────────────────────
// All getters return nullable types (int?, double?, bool?, String?, List<String>?)
// The ?? operator provides a safe fallback when the key doesn't exist yet:
final int safeCount = prefs.getInt('loginCount') ?? 0;
final bool safeDark = prefs.getBool('isDarkMode') ?? false;
final String safeName = prefs.getString('username') ?? 'Guest';
}
5. Async Loading in initState — The 3 Correct Patterns
This is one of the most-searched SharedPreferences questions on Stack Overflow. The problem: initState() is not async, but loading preferences requires a Future. Here are all three correct patterns:
// ❌ Wrong — await is not allowed directly in initState
@override
void initState() {
super.initState();
final prefs = await SharedPreferences.getInstance(); // compile error!
}
Pattern 1: Async method called from initState (Recommended)
Define the loading logic in a separate Future<void> method. Call it from initState without await (fire-and-forget). Use setState inside the async method to trigger a rebuild when data arrives:
class _SettingsPageState extends State<SettingsPage> {
bool _isDarkMode = false;
String _language = 'en';
double _textScale = 1.0;
bool _isLoading = true; // show spinner until prefs load
@override
void initState() {
super.initState();
_loadPreferences(); // ← call without await — fire and forget
}
// The async work happens here
Future<void> _loadPreferences() async {
final prefs = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
allowList: {'isDarkMode', 'language', 'textScale'},
),
);
// setState triggers a rebuild with the loaded values
setState(() {
_isDarkMode = prefs.getBool('isDarkMode') ?? false;
_language = prefs.getString('language') ?? 'en';
_textScale = prefs.getDouble('textScale') ?? 1.0;
_isLoading = false; // hide spinner
});
}
Future<void> _saveDarkMode(bool value) async {
final prefs = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
allowList: {'isDarkMode'},
),
);
await prefs.setBool('isDarkMode', value);
setState(() => _isDarkMode = value);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
value: _isDarkMode, // reads local state, NOT prefs directly
onChanged: _saveDarkMode,
),
ListTile(
title: const Text('Language'),
subtitle: Text(_language),
),
],
),
);
}
}
Pattern 2: Pre-load in main() and pass down
The cleanest pattern for production apps — load preferences before the first frame, pass the loaded instance down through the widget tree. No loading spinner, no initState complexity:
// ── main.dart ─────────────────────────────────────────────────────────────
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // required before any async in main
// Load preferences ONCE before the app starts
final prefs = await SharedPreferences.getInstance();
final isDarkMode = prefs.getBool('isDarkMode') ?? false;
// Pass the loaded value (or the prefs instance) to the root widget
runApp(MyApp(initialDarkMode: isDarkMode));
}
// ── MyApp uses the pre-loaded value immediately ────────────────────────────
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.initialDarkMode});
final bool initialDarkMode;
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: initialDarkMode ? ThemeData.dark() : ThemeData.light(),
home: const HomePage(),
);
}
}
Pattern 3: FutureBuilder (for isolated widget loading)
Use FutureBuilder when the preference determines the entire widget subtree and you don’t want a loading state at the page level. Store the Future in a field — never call getInstance() directly in the future: parameter:
class _OnboardingCheckState extends State<OnboardingCheck> {
// ✅ Store the Future in a field — NOT in FutureBuilder(future: ...) directly
// If you call getInstance() inside future:, it fires on every rebuild
late final Future<bool> _hasSeenOnboarding;
@override
void initState() {
super.initState();
_hasSeenOnboarding = _loadOnboardingFlag();
}
Future<bool> _loadOnboardingFlag() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool('hasSeenOnboarding') ?? false;
}
@override
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: _hasSeenOnboarding, // ← reference to stored Future
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
final hasSeen = snapshot.data!;
return hasSeen ? const HomePage() : const OnboardingScreen();
},
);
}
}
6. Key Naming Conventions and the AppPrefs Wrapper
Every SharedPreferences entry is stored under a string key. Typos in keys are silent bugs — getString('username') and getString('user_name') both compile fine but return null for the wrong key with no error. This is the most common cause of unexplained null values in beginner Flutter apps.
Step 1: Centralise all keys in a constants class
// ❌ Anti-pattern: scattered string literals across files
// settings_screen.dart:
prefs.setBool('darkMode', value); // 'darkMode'
// home_screen.dart:
prefs.getBool('dark_mode') ?? false; // 'dark_mode' — typo! Returns null silently
// ─────────────────────────────────────────────────────────────────────────
// ✅ Correct: all keys in one place
// lib/core/pref_keys.dart
class PrefKeys {
PrefKeys._(); // prevent instantiation
// User settings
static const String isDarkMode = 'isDarkMode';
static const String language = 'language';
static const String textScaleFactor = 'textScaleFactor';
static const String sortOrder = 'sortOrder';
// Onboarding / state
static const String hasSeenOnboarding = 'hasSeenOnboarding';
static const String isFirstLaunch = 'isFirstLaunch';
// User session (non-sensitive)
static const String lastEmail = 'lastEmail';
static const String lastUsername = 'lastUsername';
// App behaviour
static const String notificationsEnabled = 'notificationsEnabled';
static const String loginCount = 'loginCount';
}
// Usage — the compiler catches PrefKeys.isDarkModee (typo) immediately:
prefs.getBool(PrefKeys.isDarkMode) ?? false; // safe
prefs.getString(PrefKeys.language) ?? 'en'; // safe
Step 2: The AppPrefs Wrapper Service (for larger apps)
For apps with more than a handful of preferences, wrap SharedPreferences in a typed service class. This gives you a single source of truth, typed getters and setters with baked-in defaults, and makes it easy to unit test (inject a mock):
// lib/services/app_prefs.dart
import 'package:shared_preferences/shared_preferences.dart';
class AppPrefs {
AppPrefs._(this._prefs);
final SharedPreferences _prefs;
// ── Factory: load the prefs instance and return a ready wrapper ───────
static Future<AppPrefs> load() async {
final prefs = await SharedPreferences.getInstance();
return AppPrefs._(prefs);
}
// ── Private key constants — no magic strings outside this file ─────────
static const _isDarkMode = 'isDarkMode';
static const _language = 'language';
static const _hasSeenOnboarding = 'hasSeenOnboarding';
static const _lastEmail = 'lastEmail';
static const _textScaleFactor = 'textScaleFactor';
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);
// Onboarding
bool get hasSeenOnboarding => _prefs.getBool(_hasSeenOnboarding) ?? false;
Future<void> setHasSeenOnboarding(bool val) =>
_prefs.setBool(_hasSeenOnboarding, val);
// Last email (for pre-filling login form)
String get lastEmail => _prefs.getString(_lastEmail) ?? '';
Future<void> setLastEmail(String val) => _prefs.setString(_lastEmail, val);
// Text scale
double get textScaleFactor => _prefs.getDouble(_textScaleFactor) ?? 1.0;
Future<void> setTextScaleFactor(double val) =>
_prefs.setDouble(_textScaleFactor, val);
// Notifications
bool get notificationsEnabled =>
_prefs.getBool(_notificationsEnabled) ?? true;
Future<void> setNotificationsEnabled(bool val) =>
_prefs.setBool(_notificationsEnabled, val);
// Login count
int get loginCount => _prefs.getInt(_loginCount) ?? 0;
Future<void> incrementLoginCount() =>
_prefs.setInt(_loginCount, loginCount + 1);
// ── Logout: clear only user-specific keys ────────────────────────────
Future<void> clearUserData() async {
await _prefs.remove(_lastEmail);
await _prefs.remove(_hasSeenOnboarding);
// isDarkMode, language, textScaleFactor survive logout intentionally
}
}
// ── Usage in main.dart ────────────────────────────────────────────────────
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final appPrefs = await AppPrefs.load();
runApp(MyApp(appPrefs: appPrefs));
}
// ── Usage anywhere in your app ────────────────────────────────────────────
// final isDark = appPrefs.isDarkMode; // typed, no ?? needed
// await appPrefs.setDarkMode(true); // clear intent
// await appPrefs.clearUserData(); // explicit logout
Key Naming Rules
| Rule | Example |
|---|---|
| Use camelCase (matches Dart conventions) | isDarkMode not is_dark_mode |
| Be descriptive, avoid abbreviations | hasSeenOnboarding not hso |
| Group multi-user keys with a prefix | user_alice_theme |
| allowList keys must exactly match stored keys | allowList: {'isDarkMode'} not {'is_dark'} |
| Internal flutter. prefix (legacy API) | Your 'isDarkMode' is stored as 'flutter.isDarkMode' on disk — you never see this in code but native Android tools do |
7. The Switch Snapping Bug — Root Cause and Fix
This is the most viral SharedPreferences bug. When you use a Switch or SwitchListTile to toggle a preference, the toggle visually snaps back to its old position immediately after the user taps it — even though the preference was saved correctly. Here is exactly why it happens and how to fix it.
Why it snaps
// ❌ Buggy — Switch reads directly from prefs on every build
Switch(
value: prefs.getBool('isDarkMode') ?? false, // ← reads stale cached value
onChanged: (val) async {
await prefs.setBool('isDarkMode', val); // async write
setState(() {}); // triggers rebuild
// Problem: prefs.setBool is async. When setState triggers a rebuild,
// the builder calls prefs.getBool('isDarkMode') AGAIN.
// If the write hasn't completed yet, it returns the OLD value.
// Switch sees old value → snaps back to old position.
// Even after the write completes, the in-memory cache may be stale.
},
)
Fix 1: Separate local state from storage (Recommended)
class _DarkModeToggleState extends State<DarkModeToggle> {
bool _isDarkMode = false; // ← local state variable — source of truth for UI
@override
void initState() {
super.initState();
_loadDarkMode();
}
Future<void> _loadDarkMode() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_isDarkMode = prefs.getBool('isDarkMode') ?? false;
});
}
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: const Text('Dark Mode'),
value: _isDarkMode, // ← reads LOCAL STATE, not prefs directly
onChanged: (val) async {
setState(() => _isDarkMode = val); // 1. Update UI immediately (no snap)
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isDarkMode', val); // 2. Persist in background
// UI already shows the new value — persistence happens asynchronously
},
);
}
}
// The key insight:
// - The SWITCH reads _isDarkMode (a local bool) — always in sync, never async
// - SharedPreferences is the PERSISTENCE layer — updated asynchronously
// - They are two separate concerns — don't mix them
Fix 2: Provider / Riverpod (for app-wide dark mode)
// For dark mode that affects the whole app (ThemeMode in MaterialApp),
// a ChangeNotifier wrapping SharedPreferences is the production pattern.
// The Switch listens to the notifier — not SharedPreferences directly.
class ThemeNotifier extends ChangeNotifier {
ThemeMode _mode = ThemeMode.light;
ThemeMode get mode => _mode;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_mode = (prefs.getBool('isDarkMode') ?? false)
? ThemeMode.dark
: ThemeMode.light;
notifyListeners();
}
Future<void> toggle(bool isDark) async {
_mode = isDark ? ThemeMode.dark : ThemeMode.light;
notifyListeners(); // UI updates immediately
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isDarkMode', isDark); // persisted asynchronously
}
}
// MaterialApp listens to the notifier:
// themeMode: themeNotifier.mode,
// (with Provider or ChangeNotifierProvider wrapping the app)
// See our dark mode guide for the complete implementation:
// https://newblog.flutterforbeginners.com/flutter/flutter-dark-mode-toggle
8. Storing Objects via JSON (and When to Stop)
SharedPreferences only supports primitives. If you need to store a custom Dart object, the workaround is to serialize it to a JSON string. This works for one or two simple objects — beyond that, you’ve outgrown SharedPreferences:
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
// ── Define the model ──────────────────────────────────────────────────────
class UserProfile {
final String name;
final String email;
final int age;
final String avatarUrl;
const UserProfile({
required this.name,
required this.email,
required this.age,
required this.avatarUrl,
});
// Serialize to Map for JSON encoding
Map<String, dynamic> toJson() => {
'name': name,
'email': email,
'age': age,
'avatarUrl': avatarUrl,
};
// Deserialize from Map after JSON decoding
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
name: json['name'] as String,
email: json['email'] as String,
age: json['age'] as int,
avatarUrl: json['avatarUrl'] as String,
);
}
// ── Save a UserProfile ────────────────────────────────────────────────────
Future<void> saveUserProfile(UserProfile user) async {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(user.toJson()); // Map → JSON string
await prefs.setString('userProfile', jsonString);
}
// ── Load a UserProfile ────────────────────────────────────────────────────
Future<UserProfile?> loadUserProfile() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString('userProfile');
if (jsonString == null) return null; // not saved yet
final Map<String, dynamic> map = jsonDecode(jsonString);
return UserProfile.fromJson(map);
}
// ── Usage ─────────────────────────────────────────────────────────────────
Future<void> example() async {
final alice = const UserProfile(
name: 'Alice', email: '[email protected]', age: 28, avatarUrl: 'https://...',
);
await saveUserProfile(alice);
final loaded = await loadUserProfile();
print(loaded?.name); // → Alice
}
The JSON workaround becomes a code smell quickly. Stop using SharedPreferences and switch to Hive (hive_ce) when:
- You’re serialising objects with more than 5 fields
- You’re storing a list of objects (not just a single object)
- You’re querying or filtering stored objects
- You find yourself writing manual JSON parsing in multiple places
9. Clearing on Logout — remove() vs clear() vs allowList
prefs.clear() wipes everything — including dark mode, language, and other app settings the user expects to survive logout. This is a subtle but common bug. There are three approaches:
// ── Approach 1: Selective remove (correct for most apps) ─────────────────
// Only clear user-specific data. App-level settings survive.
Future<void> onLogout() async {
final prefs = await SharedPreferences.getInstance();
// ✅ Remove user-session data
await prefs.remove('lastEmail');
await prefs.remove('authToken'); // (ideally in flutter_secure_storage)
await prefs.remove('hasSeenOnboarding');
await prefs.remove('loginCount');
// ❌ DO NOT remove:
// 'isDarkMode' — user expects their theme preference to survive logout
// 'language' — user expects their language preference to survive
// 'textScaleFactor' — accessibility setting, should always persist
}
// ── Approach 2: Full clear + re-apply globals ─────────────────────────────
// Nuke everything, then restore the settings you want to keep.
Future<void> onLogoutFullClear() async {
final prefs = await SharedPreferences.getInstance();
// Save global preferences first
final isDark = prefs.getBool('isDarkMode');
final language = prefs.getString('language');
final textScale = prefs.getDouble('textScaleFactor');
await prefs.clear(); // wipe everything
// Restore globals
if (isDark != null) await prefs.setBool('isDarkMode', isDark);
if (language != null) await prefs.setString('language', language);
if (textScale != null) await prefs.setDouble('textScaleFactor', textScale);
}
// ── Approach 3: allowList clear (new API — cleanest) ──────────────────────
// The SharedPreferencesAsync API supports clearing only specified keys.
// Most surgical — no risk of accidentally clearing the wrong keys.
Future<void> onLogoutWithAllowList() async {
const asyncPrefs = SharedPreferencesAsync();
// Only clears these specific keys — everything else is untouched
await asyncPrefs.clear(
allowList: {'lastEmail', 'hasSeenOnboarding', 'loginCount'},
);
}
// ── AppPrefs wrapper approach (best for larger apps) ──────────────────────
// Define a dedicated logout() method in your AppPrefs service.
// This makes the intent explicit and lives in one place.
// See the AppPrefs class in Section 6 above — clearUserData() does this.
10. Security Pitfalls
SharedPreferences stores data as plaintext — never store secrets
// ❌ NEVER store these in SharedPreferences:
await prefs.setString('authToken', 'Bearer eyJhb...'); // security risk
await prefs.setString('password', 'hunter2'); // never store passwords
await prefs.setString('creditCard', '4111-1111-1111-1111'); // PCI violation
await prefs.setString('ssn', '123-45-6789'); // PII violation
// ✅ Use flutter_secure_storage for sensitive values:
// - Auth tokens and refresh tokens
// - Encryption keys
// - Biometric auth flags
// - Any personally identifiable information (PII)
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const storage = FlutterSecureStorage();
// Stores in Android Keystore / iOS Keychain — encrypted at rest
await storage.write(key: 'authToken', value: 'Bearer eyJhb...');
final token = await storage.read(key: 'authToken');
await storage.delete(key: 'authToken'); // on logout
The Multi-Isolate Firebase Messaging Cache Bug
// Problem: Firebase Messaging background handler runs in a separate Dart isolate.
// The legacy SharedPreferences instance in EACH isolate has its own separate
// singleton and in-memory cache.
//
// If the background isolate writes a preference:
// background isolate: prefs.setBool('newNotification', true)
//
// The UI isolate's cache is stale — it still shows false.
// The UI won't see the new value until prefs.reload() is called.
// ❌ Bug: stale cache when Firebase Messaging background handler writes prefs
final prefs = await SharedPreferences.getInstance();
final hasNew = prefs.getBool('newNotification') ?? false; // might be stale!
// ✅ Fix Option 1: call reload() before reading if Firebase Messaging is involved
final prefs = await SharedPreferences.getInstance();
await prefs.reload(); // fetch fresh from disk
final hasNew = prefs.getBool('newNotification') ?? false;
// ✅ Fix Option 2 (better): use SharedPreferencesAsync — no cache, always fresh
const asyncPrefs = SharedPreferencesAsync();
final hasNew = await asyncPrefs.getBool('newNotification') ?? false;
11. Migrating from Legacy to the New API
If you have an existing app using the legacy SharedPreferences.getInstance() API and want to migrate to SharedPreferencesAsync, the package ships an official migration utility. It is idempotent — safe to call on every 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();
// Load the legacy instance
final legacyPrefs = await SharedPreferences.getInstance();
// Run migration — copies all legacy keys to the new async storage format
// The migrationCompletedKey tracks whether migration has already run,
// so this is safe to call on every app launch without re-migrating
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
legacySharedPreferencesInstance: legacyPrefs,
sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
migrationCompletedKey: 'sp_migration_completed', // stored in prefs itself
);
// After migration, use the new API everywhere:
const asyncPrefs = SharedPreferencesAsync();
final isDark = await asyncPrefs.getBool('isDarkMode') ?? false;
runApp(MyApp(initialDarkMode: isDark));
}
12. Common Mistakes Reference Table
| Mistake | What happens | Fix |
|---|---|---|
Using await directly in initState | Dart compile error | Extract to async method, call without await |
Reading prefs directly as Switch value | Visual snap-back on toggle | Use local bool state variable; update UI first, persist second |
| Scattering key strings across files | Silent null returns from 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/passwords in SharedPreferences | Security vulnerability — plaintext on disk | Use flutter_secure_storage |
| Using legacy API with Firebase Messaging | Stale cached reads in UI isolate | Switch to SharedPreferencesAsync or call prefs.reload() |
Not calling WidgetsFlutterBinding.ensureInitialized() | Crash when using prefs before runApp | Add as first line of main() |
Calling getInstance() inside FutureBuilder(future:) | New Future on every rebuild → flickering / multiple reads | Store the Future in a field in initState |
| Storing 3+ objects via JSON serialisation | Unmaintainable, error-prone | Migrate to Hive (hive_ce) |
13. Interview Q&A
A: SharedPreferences is a Flutter plugin that wraps platform-native key-value storage (NSUserDefaults on iOS, Android SharedPreferences or Jetpack DataStore on Android, localStorage on Web). It stores five primitive types: int, double, bool, String, and List<String>. Use it for app settings and user preferences — dark mode, language selection, onboarding flags, last-used email. Don’t use it for objects, sensitive data, or anything that needs querying or filtering.
A:
initState() is a synchronous lifecycle method — Flutter calls it synchronously as part of widget insertion, and it is not declared async. The fix is to extract the async work into a separate Future<void> method and call that from initState() without await (fire-and-forget). Inside the async method, use setState() to update the widget once data is ready.
A: The bug occurs when the
Switch widget reads its value directly from SharedPreferences (e.g. value: prefs.getBool('isDarkMode') ?? false). When the user taps, the async write fires but hasn’t completed by the time Flutter rebuilds — so prefs.getBool returns the old cached value and the switch snaps back. The fix is to use a local bool state variable as the source of truth for the UI: update local state with setState immediately on tap, then persist to SharedPreferences asynchronously in the background.
A:
SharedPreferences is the legacy API — it uses an in-memory cache so reads are synchronous, but it’s being deprecated. SharedPreferencesAsync is a new API with no cache — every read hits platform storage, making it always fresh but fully async. SharedPreferencesWithCache is the recommended replacement — it combines an allowList-scoped in-memory cache (fast synchronous reads) with async writes. Use SharedPreferencesWithCache for most new projects; use SharedPreferencesAsync if Firebase Messaging or background isolates also write to the same storage.
A: Usually no.
clear() removes every stored key — including app-level settings like dark mode and language that the user expects to survive between sessions. The correct approach is selective: use prefs.remove() for each user-specific key (auth token, email, session flags) while leaving app-level preferences untouched. With the new SharedPreferencesAsync API, you can use clear(allowList: {'key1', 'key2'}) to remove only specific keys cleanly.
14. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Local Storage: SharedPreferences vs Hive vs sqflite | The hub comparison post — read this first if you’re not sure SharedPreferences is the right choice for your use case. |
| Flutter Dark Mode Toggle | Uses SharedPreferences to persist the theme preference — the most common real-world use case for this guide. |
| Notes App Part 2: Local Persistence | A complete project using SharedPreferences — see every pattern from this guide in context. |
| Flutter Widgets: Stateless vs Stateful | The initState loading pattern depends on understanding the StatefulWidget lifecycle — read this first. |
| Dart Crash Course (Part 0) | SharedPreferences uses async/await throughout — the Dart crash course covers both in depth. |
