Local Data Storage

SharedPreferences: Real-World Patterns and Limitations

16 min Lesson 3 of 12

SharedPreferences: Real-World Patterns and Limitations

In the previous lesson you learned the basics of reading and writing values with SharedPreferences. In this lesson you will move beyond individual calls and discover how professional Flutter engineers structure SharedPreferences access in production apps — using a dedicated service class, persisting the settings users care about most, handling logout cleanly, and recognising the hard limits that tell you when it is time to reach for a relational database.

Wrapping SharedPreferences in a Service Class

Calling SharedPreferences.getInstance() scattered across dozens of widgets creates tight coupling and makes testing almost impossible. The standard solution is to encapsulate every read and write inside a single service class. Consumers never touch SharedPreferences directly; they call typed methods on the service, which keeps your business logic clean and lets you swap the storage back-end in one place.

PreferencesService — a production-ready wrapper

import 'package:shared_preferences/shared_preferences.dart';

class PreferencesService {
  static const _keyTheme        = 'theme_mode';
  static const _keyLocale       = 'locale';
  static const _keyOnboarded    = 'onboarding_complete';
  static const _keyLastUsername = 'last_username';

  final SharedPreferences _prefs;

  // Private constructor: callers use the factory below.
  PreferencesService._(this._prefs);

  /// Call once at app startup (e.g. in main() before runApp).
  static Future<PreferencesService> create() async {
    final prefs = await SharedPreferences.getInstance();
    return PreferencesService._(prefs);
  }

  // ── Theme ────────────────────────────────────────────────
  String get themeMode => _prefs.getString(_keyTheme) ?? 'system';
  Future<void> setThemeMode(String mode) =>
      _prefs.setString(_keyTheme, mode);

  // ── Locale ───────────────────────────────────────────────
  String get locale => _prefs.getString(_keyLocale) ?? 'en';
  Future<void> setLocale(String code) =>
      _prefs.setString(_keyLocale, code);

  // ── Onboarding ───────────────────────────────────────────
  bool get isOnboarded => _prefs.getBool(_keyOnboarded) ?? false;
  Future<void> completeOnboarding() =>
      _prefs.setBool(_keyOnboarded, true);

  // ── Last username (remember me) ──────────────────────────
  String? get lastUsername => _prefs.getString(_keyLastUsername);
  Future<void> setLastUsername(String username) =>
      _prefs.setString(_keyLastUsername, username);

  // ── Logout ───────────────────────────────────────────────
  /// Clears only session data; preserves device preferences.
  Future<void> clearSessionData() async {
    await _prefs.remove(_keyLastUsername);
    // Do NOT remove _keyTheme or _keyLocale — user chose those.
  }

  /// Nuclear option: wipe every key owned by this service.
  Future<void> clearAll() => _prefs.clear();
}
Tip: Initialise PreferencesService in main() before runApp() and provide it to the widget tree via your dependency-injection layer (Provider, Riverpod, get_it, etc.). This way every widget gets the same, fully-loaded instance without waiting for an asynchronous call.

Persisting Common User Settings

The three settings that almost every app needs to persist are theme mode, locale / language, and onboarding state. Each maps naturally to a small primitive value, which is exactly what SharedPreferences excels at.

Reading preferences at app startup

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final prefs = await PreferencesService.create();

  runApp(
    MultiProvider(
      providers: [
        Provider<PreferencesService>.value(value: prefs),
        ChangeNotifierProvider(
          create: (_) => ThemeNotifier(initialMode: prefs.themeMode),
        ),
        ChangeNotifierProvider(
          create: (_) => LocaleNotifier(initialLocale: prefs.locale),
        ),
      ],
      child: const MyApp(),
    ),
  );
}

// In a settings screen:
Future<void> _onThemeChanged(String newMode) async {
  final prefs = context.read<PreferencesService>();
  await prefs.setThemeMode(newMode);           // persists to disk
  context.read<ThemeNotifier>().setMode(newMode); // updates UI
}
Note: Always call WidgetsFlutterBinding.ensureInitialized() before any await in main(). Without it the Flutter engine may not yet be ready to handle platform channel calls, causing the SharedPreferences.getInstance() future to hang.

Clearing Data on Logout

Logout handling is where many apps make mistakes. Never call prefs.clear() indiscriminately on logout. That erases the user's theme and language choices — preferences tied to the device, not the account. Distinguish between two categories:

  • Session / account data — auth tokens, cached username, unread counts → clear on logout
  • Device / UI preferences — theme, locale, font size → keep on logout

The clearSessionData() method in the service class above demonstrates this pattern: it removes only the keys that belong to the current session while preserving the keys that reflect the user's personal device settings.

Understanding Size and Type Limitations

SharedPreferences is backed by a simple XML file on Android and a plist on iOS. It is optimised for small configuration values, not bulk data. Understanding its constraints helps you avoid subtle bugs and poor user experiences:

  • Supported types only: String, int, double, bool, List<String>. No DateTime, no Map, no custom objects.
  • No transactions: Writing two keys is not atomic. If the process crashes between writes, you can get a partially-updated state.
  • Loaded entirely into memory: On app launch the entire file is read into RAM. Storing large strings (e.g. a full JSON blob of 500 KB) slows startup time for every user.
  • No querying: You cannot filter, sort, or search keys. Every access is by exact key name.
  • Namespace collisions: All keys share a single flat namespace. Use a consistent naming convention (e.g. feature_keyname) to avoid conflicts when the project grows.
Warning: Do not store sensitive data such as auth tokens, passwords, or personal identifiers in SharedPreferences. On Android the underlying XML file is not encrypted by default. Use flutter_secure_storage for anything that must be kept confidential.

When to Upgrade to a Database

Reach for SQLite (via sqflite or drift) or Hive / Isar when you encounter any of the following signals:

  • You need to store a list of objects (e.g. bookmarked articles, cached search results).
  • You require querying — finding items by a field value, sorting, or aggregating.
  • The data size exceeds a few kilobytes of total serialised content.
  • You need transactional writes to keep multiple related fields consistent.
  • You are storing user-generated content that should survive re-installs (use a remote database).
Key Takeaway: SharedPreferences shines for a handful of small primitive settings — theme, locale, onboarding flag, last-used values. Centralise all access in a service class, be surgical about what you clear on logout, and migrate to a proper database the moment your data model grows beyond flat key-value primitives.