İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter: Local Data Storage with SharedPreferences

Ahmet Balaman

9 min read

FlutterSharedPreferencesLocal StorageThemeAsyncSettings
Flutter: Local Data Storage with SharedPreferences

The user picked the dark theme, closed the app, opened it again, and everything was back to light. That is the classic sign of a setting that only lives in memory. For small values that must survive an app restart, the shared_preferences package published by the Flutter team is the first tool to reach for. In this post I cover the two APIs the package recommends today (SharedPreferencesAsync and SharedPreferencesWithCache), the legacy SharedPreferences.getInstance() route, a complete example that makes the theme preference persistent, and something just as important: what you should not store there.

What Is SharedPreferences?

The package wraps each platform's own simple key-value store behind one Dart interface: NSUserDefaults on iOS and macOS, DataStore Preferences by default on Android with the new APIs (Android SharedPreferences with the legacy API), and the browser's localStorage on the web. The types you can store are limited: int, double, bool, String and List<String>.

The package documentation states one warning plainly: writes may be persisted to disk asynchronously, and there is no guarantee that data has reached the disk after the method returns, so the plugin must not be used for critical data. In other words, this is the place for preferences like theme, language or "has the onboarding been seen", where losing a value isn't the end of the world.

Setup

flutter pub add shared_preferences

According to the support table on pub.dev, Android needs at least SDK 24 and iOS at least 13.0. After adding the package, run the app with a full rebuild rather than hot reload; newly added native code only kicks in that way.

Three APIs: Which One to Pick?

Since version 2.3.0 the package offers three separate APIs. According to the docs, the legacy SharedPreferences will be deprecated in the future, and the other two are recommended for new code:

API Reads Cache When?
SharedPreferencesAsync Every read needs await None, reads from the platform every time When data can change from another isolate or native code; the safest default
SharedPreferencesWithCache Loaded once at startup, then synchronous Yes, limited by allowList When you want to read values synchronously in build
SharedPreferences (legacy) Synchronous after getInstance() Yes, all keys Existing projects; avoid in new code

The cached APIs have one weak spot: if a background isolate (for example a separate engine started by a notification plugin) or native code writes to the same store, your cache goes stale. SharedPreferencesAsync always reads the current value in that situation; with SharedPreferencesWithCache you need to call reloadCache() before reading.

If you can't decide, this rule works for most projects: if you need to read the value synchronously in the UI (theme, language, text size), use SharedPreferencesWithCache; if you only read and write it at specific moments, such as once at startup or when a button is pressed, use SharedPreferencesAsync. Both use the same platform store, so they can live side by side in the same project.

Basic Usage with SharedPreferencesAsync

import 'package:shared_preferences/shared_preferences.dart';

Future<void> example() async {
  final prefs = SharedPreferencesAsync();

  // Write
  await prefs.setBool('onboarding_done', true);
  await prefs.setInt('launch_count', 3);
  await prefs.setString('last_tab', 'profile');
  await prefs.setStringList('recent_searches', ['flutter', 'dart']);

  // Read: returns null if the key doesn't exist
  final bool onboardingDone = await prefs.getBool('onboarding_done') ?? false;
  final int launchCount = await prefs.getInt('launch_count') ?? 0;
  final String? lastTab = await prefs.getString('last_tab');

  // Delete
  await prefs.remove('last_tab');
  await prefs.clear(allowList: {'onboarding_done', 'launch_count'});
}

Every getter can return null, because on the very first launch no key exists yet. Supplying a sensible default with ?? should become a habit. Passing an allowList to clear() matters too: clear() without parameters can also remove values written to the same store by other packages or native code, which is why the package docs strongly recommend providing the list.

SharedPreferencesWithCache: Synchronous Reads

Reading a value like the theme with await in every build isn't practical. SharedPreferencesWithCache loads the keys you allow into memory once at startup; after that reads are synchronous, while writes go to both the cache and the disk:

final prefs = await SharedPreferencesWithCache.create(
  cacheOptions: const SharedPreferencesWithCacheOptions(
    allowList: {'onboarding_done', 'launch_count'},
  ),
);

final count = prefs.getInt('launch_count') ?? 0; // no await
await prefs.setInt('launch_count', count + 1);

The allowList works like a seat belt: if you try to read or write a key that isn't on the list, you get an ArgumentError. Forgetting to add a new setting to the list is the error you'll hit most often with this API. If you omit allowList entirely, all keys are cached, but the docs don't recommend that.

The Legacy API: SharedPreferences.getInstance()

Most examples on the internet are still written with this API, so you need to recognize it:

final prefs = await SharedPreferences.getInstance();
await prefs.setInt('launch_count', 1);
final count = prefs.getInt('launch_count') ?? 0;

If it works in an existing project, there's no need to rush. But be careful when you switch to the new API: on Android the new APIs use DataStore by default, a different store from the file the legacy API wrote to. If you only change the class name, users who update the app will see their saved settings "disappear". The package provides a migration function for exactly this:

import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';

Future<void> migratePreferences() async {
  final legacy = await SharedPreferences.getInstance();
  await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
    legacySharedPreferencesInstance: legacy,
    sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
    migrationCompletedKey: 'prefs_migration_done',
  );
}

Call this at startup, before using the new API. It marks the migration as done with the key you pass; as long as migrationCompletedKey stays the same, calling it on every launch doesn't lose data.

Showing Onboarding Only on First Launch

The most common use is the question "has the user seen the onboarding screens?". The value is read once at startup and decides the first screen:

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

const onboardingDoneKey = 'onboarding_done';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final done =
      await SharedPreferencesAsync().getBool(onboardingDoneKey) ?? false;
  runApp(MaterialApp(home: done ? const HomePage() : const OnboardingPage()));
}

// In OnboardingPage's State class, the "Get started" button on the last page:
Future<void> _finish() async {
  await SharedPreferencesAsync().setBool(onboardingDoneKey, true);
  if (!mounted) return;
  Navigator.of(context).pushReplacement(
    MaterialPageRoute(builder: (context) => const HomePage()),
  );
}

pushReplacement removes the onboarding page from the stack, so the back button doesn't take the user back to it; page transitions are covered in detail in the Navigator post. The mounted check after the await prevents using a stale context if the page was closed while the write was running.

Mini Scenario: Making the Theme Preference Persistent

In the theme example from the Provider post, the preference only lived in memory. (Building the light and dark themes themselves with ThemeData is covered in the Theme and ThemeData post; here we only store the choice.) Now let's make the same idea persistent. There are three parts: a small class that talks to the store, a ChangeNotifier that holds the theme, and the app itself.

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

class SettingsStore {
  SettingsStore._(this._prefs);

  static const _themeModeKey = 'theme_mode';

  final SharedPreferencesWithCache _prefs;

  static Future<SettingsStore> create() async {
    final prefs = await SharedPreferencesWithCache.create(
      cacheOptions: const SharedPreferencesWithCacheOptions(
        allowList: {_themeModeKey},
      ),
    );
    return SettingsStore._(prefs);
  }

  ThemeMode get themeMode {
    final saved = _prefs.getString(_themeModeKey);
    return ThemeMode.values.asNameMap()[saved] ?? ThemeMode.system;
  }

  Future<void> saveThemeMode(ThemeMode mode) =>
      _prefs.setString(_themeModeKey, mode.name);
}

class ThemeController extends ChangeNotifier {
  ThemeController(this._store) : _mode = _store.themeMode;

  final SettingsStore _store;
  ThemeMode _mode;

  ThemeMode get mode => _mode;

  Future<void> setMode(ThemeMode mode) async {
    if (mode == _mode) return;
    _mode = mode;
    notifyListeners(); // Update the UI first
    await _store.saveThemeMode(mode); // Then persist
  }
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final store = await SettingsStore.create();
  runApp(MyApp(themeController: ThemeController(store)));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.themeController});

  final ThemeController themeController;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: themeController,
      builder: (context, child) => MaterialApp(
        theme: ThemeData(colorSchemeSeed: Colors.indigo),
        darkTheme: ThemeData(
          colorSchemeSeed: Colors.indigo,
          brightness: Brightness.dark,
        ),
        themeMode: themeController.mode,
        home: SettingsPage(themeController: themeController),
      ),
    );
  }
}

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key, required this.themeController});

  final ThemeController themeController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Theme'),
            const SizedBox(height: 8),
            SegmentedButton<ThemeMode>(
              segments: const [
                ButtonSegment(value: ThemeMode.system, label: Text('System')),
                ButtonSegment(value: ThemeMode.light, label: Text('Light')),
                ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
              ],
              selected: {themeController.mode},
              onSelectionChanged: (selection) =>
                  themeController.setMode(selection.first),
            ),
          ],
        ),
      ),
    );
  }
}

There are four decisions in this code:

  • The value is read before runApp. The theme is right on the first frame; there's no flash of the light theme followed by a switch to dark half a second later. To access a plugin before runApp, WidgetsFlutterBinding.ensureInitialized() is required.
  • The enum is stored by name. mode.name produces text like "dark", and asNameMap() turns it back. Storing the position (index) means the wrong theme opens once someone inserts a new value into the enum later. For more on working with enums, see the Dart enum post.
  • If the saved value is missing or broken, ?? ThemeMode.system kicks in; the app never crashes.
  • UI first, then disk. notifyListeners() is called before the write, so the user never feels a delay.

I used ListenableBuilder here so the example runs without extra packages. If your project uses Provider, you hand the same ThemeController to the tree with ChangeNotifierProvider and read it with context.watch; with Riverpod you provide SettingsStore from a provider. The "persist" step in the settings screen with Switch, SegmentedButton and Slider maps exactly onto this structure.

Storing a Small Object

Objects aren't among the supported types, but you can turn a small object into JSON text and store it as a String:

import 'dart:convert';

class SearchFilter {
  const SearchFilter({required this.query, required this.onlyFree});

  final String query;
  final bool onlyFree;

  Map<String, dynamic> toJson() => {'query': query, 'onlyFree': onlyFree};

  factory SearchFilter.fromJson(Map<String, dynamic> json) => SearchFilter(
        query: json['query'] as String,
        onlyFree: json['onlyFree'] as bool,
      );
}

Future<void> saveFilter(SharedPreferencesAsync prefs, SearchFilter filter) =>
    prefs.setString('last_filter', jsonEncode(filter.toJson()));

Future<SearchFilter?> loadFilter(SharedPreferencesAsync prefs) async {
  final raw = await prefs.getString('last_filter');
  if (raw == null) return null;
  return SearchFilter.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}

The fromJson and toJson pattern is the same one you use for API data; I explain it in detail in the HTTP requests post. This approach fits a single small object like the last used filter. If you've started storing a list of hundreds of records as one JSON string, you're using the wrong tool: changing one record means reading and rewriting the whole list, you filter and sort by hand, and with SharedPreferencesWithCache that entire string sits in memory all the time. A database updates a single row and runs the query for you.

What Goes Where?

Data The right place
Theme, language, notification preference, "onboarding seen" flag shared_preferences
Session token, password, API key flutter_secure_storage
Many structured records like notes, orders, messages A database such as sqflite or drift
Images, PDFs, large files The file system (the app folder via path_provider)

Why the token doesn't belong here matters: shared_preferences doesn't encrypt data. On Android it sits unencrypted in a file in the app's data folder, and on the web it lives in localStorage, which can be read from the browser's developer tools. flutter_secure_storage, by contrast, uses the Keychain on iOS and encrypted storage on Android, and its usage is almost the same: await storage.write(key: 'token', value: token) and await storage.read(key: 'token').

Common Mistakes

1. Forgetting ensureInitialized before runApp

Symptom: the app fails right at launch with Binding has not yet been initialized. If you access a plugin in main before runApp, the first line has to be WidgetsFlutterBinding.ensureInitialized();.

2. Scattering keys across the code as strings

If you write 'themeMode' in one place and 'theme_mode' in another, the value is never read and you get no error either, just the default. Keep keys as constants in one class; if you use SharedPreferencesWithCache, use the same constants for the allowList. Renaming a key in a published app has the same effect: existing users' values stay under the old name and are never read again. Pick a key name once, and if you must rename it, write a small migration that reads the old name and moves the value to the new one.

3. Switching from the legacy API to the new one and "losing" data

Symptom: after an update, users' settings appear to be reset. On Android the legacy and new APIs write to different stores. Do the switch with migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary shown above.

4. Keeping sensitive or critical data here

Tokens and passwords are stored unencrypted, and for data that must not be lost, like a payment record, there's no guarantee the write reaches the disk. The first needs secure storage; the second needs a database and ideally a record on the server.

5. Re-reading the value with a FutureBuilder on every screen

If every screen sets up a FutureBuilder for await prefs.getBool(...) on open, you get duplicated code plus a brief loading moment every time. Load app-wide settings once at startup, keep them in a controller like the one above, and let the screens read from it.

Frequently Asked Questions

Does the screen update automatically when a value changes?

No. shared_preferences offers no change stream or listener; it only writes and reads. If you want the UI to react, keep the value in a ChangeNotifier (or Provider, Riverpod), and on a change both notify listeners and write to the store. The ThemeController above does exactly that.

Is the data gone when the app is uninstalled and reinstalled?

Usually yes, but watch for two exceptions. On Android, if Auto Backup is on (it is by default), app data can be backed up to the user's Google account and restored on reinstall, so the "onboarding seen" flag may come back as true on a fresh install. On iOS, the Keychain entries used by flutter_secure_storage can remain after the app is deleted. Don't build logic that relies on or ignores these behaviors; design explicitly what should happen on reinstall.

How do I use SharedPreferences in widget tests?

Tests have no real platform store. For the legacy API, SharedPreferences.setMockInitialValues({}) sets up an in-memory store. For the new APIs, add the shared_preferences_platform_interface package as a dev dependency and run SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); at the start of the test; from then on SharedPreferencesAsync and SharedPreferencesWithCache use that in-memory store.

Comments