Local Storage

Everything covered in the last two lessons — ChangeNotifier, Riverpod, Bloc — lives entirely in memory. The moment the app process is killed, that state is gone. Most real apps need at least some data to survive an app restart: a logged-in flag, a chosen theme, a cached list of favorites, or a full offline dataset. Flutter doesn't bundle one single "save this" API — instead, a small number of well-established packages each target a different shape of data. This lesson covers the two you'll reach for most, plus a third worth knowing about.

shared_preferences: Simple Key-Value Storage

shared_preferences is the standard package for storing small, simple pieces of data as key-value pairs — a username, a "dark mode enabled" flag, an onboarding-seen boolean, a saved sort order. It is not designed for large amounts of data or anything relational; think of it as a small persistent dictionary, backed by UserDefaults on iOS and SharedPreferences on Android under the hood.

Rule of thumb: if the data fits comfortably in a handful of named values (strings, booleans, numbers, string lists), shared_preferences is the right tool. If you find yourself wanting to store a list of complex objects or query the data, that's a sign you actually need sqflite or hive instead.

Saving a Value

Getting access to the storage instance is async — SharedPreferences.getInstance() returns a Future because it has to actually read from the device's real storage layer the first time it's called, which takes a small but non-zero amount of time. This is the exact same async/await pattern covered in HTTP Requests & APIs for network calls — reading from disk, like reading from the network, is never instantaneous, so Dart models it as asynchronous work you await rather than blocking the whole app while it happens.

Future<void> saveUsername(String name) async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('username', name);
  // Also available: setBool, setInt, setDouble, setStringList
  await prefs.setBool('onboarding_complete', true);
}

Reading a Value Back

Reading uses the exact same getInstance() call, then a typed getter. Every getter returns null if the key was never set, so it's good practice to always provide a sensible fallback with the ?? operator.

Future<String> loadUsername() async {
  final prefs = await SharedPreferences.getInstance();
  // getString() returns null if 'username' was never saved
  return prefs.getString('username') ?? 'Guest';
}

Future<void> clearUsername() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.remove('username');
}
Tip: SharedPreferences.getInstance() caches its result internally after the first call within the same app run, so calling it repeatedly is cheap — you don't need to manually store the instance yourself, though many apps do anyway for convenience.

sqflite: A Real Local SQLite Database

When your data is actually structured and relational — orders with line items, notes with tags, anything you'd want to filter, sort, or join — shared_preferences stops being a good fit. sqflite gives you a genuine local SQLite database, with real SQL tables and queries, running entirely on the device. It's a bigger topic than shared_preferences, so this section stays intentionally brief — enough to recognize the shape of it.

Future<Database> openAppDatabase() async {
  final databasesPath = await getDatabasesPath();
  final path = join(databasesPath, 'app.db');

  return openDatabase(
    path,
    version: 1,
    onCreate: (db, version) async {
      // Runs once, the very first time the database is created
      await db.execute('''
        CREATE TABLE notes(
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          title TEXT NOT NULL,
          created_at TEXT NOT NULL
        )
      ''');
    },
  );
}

Future<void> insertNote(Database db, String title) async {
  await db.insert('notes', {
    'title': title,
    'created_at': DateTime.now().toIso8601String(),
  });
}

Future<List<Map<String, Object?>>> fetchNotes(Database db) async {
  // Returns rows as a List of Maps, newest first
  return db.query('notes', orderBy: 'created_at DESC');
}

Every sqflite operation — opening the database, inserting, querying — is async for the same underlying reason shared_preferences is: it's real disk I/O. In a larger app, this raw SQL layer is usually wrapped by a repository class, or paired with an ORM-style package, rather than called directly from widgets.

Choosing the Right Tool

SituationUse
A handful of simple settings, flags, or small stringsshared_preferences
Structured, relational data you need to query, filter, or sortsqflite
Structured data, but you want speed without writing SQLhive (see below)

Where Hive Fits In

hive is a popular NoSQL local storage package worth knowing conceptually, even without going deep here. It stores structured data — including whole Dart objects, via generated adapters — without requiring any actual SQL at all, and its key-value-per-box design makes it notably faster than sqflite for many common read/write patterns. It's a common choice when a team wants something more capable than shared_preferences but wants to avoid writing and maintaining raw SQL entirely.

Next: With persistence covered, continue to Themes & Styling to learn how to define your app's colors, typography, and dark mode support in one central place.