All Shards

Persistent Shard

Learn how to persist state across app sessions with PersistentShard.

Overview

PersistentShard adds automatic state persistence: data is saved to local storage and restored when the app restarts or the shard is recreated. You choose the storage backend (SharedPreferences, Hive, SQLite, or custom) by implementing the StateStorage interface.

When to Use Which?

Use CaseClass
Persist entire state (int, bool, simple objects)SimplePersistentShard<T>
Persist subset of state (e.g., todos without loading status)PersistentShard<T, K>
Need custom merge logicPersistentShard<T, K>

Start with SimplePersistentShard below for a minimal example. Use PersistentShard<T, K> when you need to persist only part of your state (e.g. a list of items but not loading/error flags).

Quick start with SimplePersistentShard

For simple cases where the state type and persistence type are the same, use SimplePersistentShard<T>. No need to override toPersistence or onLoadComplete:

class CounterShard extends SimplePersistentShard<int> {
  CounterShard()
      : super(
          0,
          serializer: IntSerializer(),
          storageFactory: () => SharedPreferencesStorage.create(),
        );

  @override
  String get persistenceKey => 'counter';

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
}

Default behavior: state is persisted as-is and restored on load. Either storage or storageFactory must be provided. Override toPersistence / onLoadComplete if you need custom behavior.

Full persistence with PersistentShard<T,K>

When state type T and persistence type K differ, use PersistentShard<T, K>. T is your full state (e.g. TodoState with loading flag); K is what you persist (e.g. List<Todo>). You implement toPersistence(state) to extract K and onLoadComplete(data) to merge loaded K back into state.

class TodoShard extends PersistentShard<TodoState, List<Todo>> {
  @override
  String get persistenceKey => 'todos';
  
  TodoShard({required StateStorage storage})
    : super(
        const TodoState(status: TodoStatus.loading, todos: []),
        storage: storage,
        serializer: TodoListSerializer(),
        autoSave: true,
        autoLoad: true,
        debounceDuration: const Duration(milliseconds: 500),
      );
  
  /// Extract only the todos list to persist
  @override
  List<Todo> toPersistence(TodoState state) => state.todos;
  
  /// Handle load completion - data is null if storage was empty
  @override
  void onLoadComplete(List<Todo>? data) {
    emit(state.copyWith(
      status: TodoStatus.loaded,
      todos: data ?? [],
    ));
  }
  
  void addTodo(String title) {
    emit(state.copyWith(
      todos: [...state.todos, Todo(id: '1', title: title)],
    ));
  }
}

Then create the shard with your storage:

final storage = await SharedPreferencesStorage.create();
final shard = TodoShard(storage: storage);
For async storage initialization, use storageFactory instead. See State Storage section for details.

Key Methods

toPersistence

Extracts the data to persist from your state. This is called whenever the state needs to be saved.

@override
List<Todo> toPersistence(TodoState state) => state.todos;

onLoadComplete

Called when load operation completes. The data parameter is:

  • The deserialized data if storage had data
  • null if storage was empty (first launch)

You're responsible for merging the loaded data into your state.

@override
void onLoadComplete(List<Todo>? data) {
  emit(state.copyWith(
    status: TodoStatus.loaded,
    todos: data ?? [],
  ));
}

State Storage

StateStorage is an interface for storage backends. You need to implement it for your storage solution.

Interface

abstract class StateStorage {
  Future<void> save(String key, String value);
  Future<String?> load(String key);
  Future<void> delete(String key);
}

delete removes the stored value for a key. After delete, a subsequent load(key) returns null; deleting a key that does not exist is a no-op.

Using Storage (Synchronous)

If you already have a storage instance ready, pass it directly using the storage parameter:

// Create storage instance
final storage = await SharedPreferencesStorage.create();

// Pass to shard
class TodoShard extends PersistentShard<TodoState, List<Todo>> {
  TodoShard({required StateStorage storage})
    : super(
        const TodoState(status: TodoStatus.loading, todos: []),
        storage: storage,
        serializer: TodoListSerializer(),
      );
  
  // ... toPersistence and onLoadComplete ...
}

// Usage
final shard = TodoShard(storage: storage);

Using StorageFactory (Asynchronous)

If your storage requires async initialization (like SharedPreferences), use storageFactory:

class TodoShard extends PersistentShard<TodoState, List<Todo>> {
  TodoShard()
    : super(
        const TodoState(status: TodoStatus.loading, todos: []),
        storageFactory: () => SharedPreferencesStorage.create(),
        serializer: TodoListSerializer(),
      );
  
  // ... toPersistence and onLoadComplete ...
}

// Usage - storage is initialized automatically
final shard = TodoShard();

When to Use Which?

  • Use storage: When you have the storage instance ready before creating the shard, or when you want to share the same storage instance across multiple shards
  • Use storageFactory: When storage initialization is async (like SharedPreferences, Hive, etc.) and you want the shard to handle initialization automatically

SharedPreferences Implementation Example

import 'package:shared_preferences/shared_preferences.dart';
import 'package:shard/shard.dart';

class SharedPreferencesStorage implements StateStorage {
  final SharedPreferences _prefs;
  
  SharedPreferencesStorage(this._prefs);
  
  static Future<SharedPreferencesStorage> create() async {
    final prefs = await SharedPreferences.getInstance();
    return SharedPreferencesStorage(prefs);
  }
  
  @override
  Future<void> save(String key, String value) async {
    await _prefs.setString(key, value);
  }
  
  @override
  Future<String?> load(String key) async {
    return _prefs.getString(key);
  }

  @override
  Future<void> delete(String key) async {
    await _prefs.remove(key);
  }
}

The delete implementation depends on your backend: with Hive use box.delete(key), with SQLite run a DELETE statement for that key, and with a file-based store delete the file that holds the value.

State Serialization

StateSerializer<K> converts the persistence data to/from strings for storage. Note that it serializes K (the persistence type), not T (the full state).

Interface

abstract class StateSerializer<K> {
  String serialize(K data);
  K deserialize(String data);
}

The easiest way to create a serializer for complex objects is using the stateSerializer factory function. It works with any type that has toJson() and fromJson() methods:

// For a single object
final userSerializer = stateSerializer<User>(
  fromJson: User.fromJson,
  toJson: (user) => user.toJson(),
);

// For a list of objects
final todoListSerializer = stateSerializer<List<Todo>>(
  fromJson: (json) => (json as List)
      .map((e) => Todo.fromJson(e as Map<String, dynamic>))
      .toList(),
  toJson: (todos) => todos.map((t) => t.toJson()).toList(),
);

Complete Example with stateSerializer

class TodoShard extends PersistentShard<TodoState, List<Todo>> {
  TodoShard()
    : super(
        const TodoState(status: TodoStatus.loading, todos: []),
        storageFactory: () => SharedPreferencesStorage.create(),
        serializer: stateSerializer<List<Todo>>(
          fromJson: (json) => (json as List)
              .map((e) => Todo.fromJson(e as Map<String, dynamic>))
              .toList(),
          toJson: (todos) => todos.map((t) => t.toJson()).toList(),
        ),
      );

  @override
  String get persistenceKey => 'todos';

  @override
  List<Todo> toPersistence(TodoState state) => state.todos;

  @override
  void onLoadComplete(List<Todo>? data) {
    emit(state.copyWith(status: TodoStatus.loaded, todos: data ?? []));
  }
}

Custom Serializer Class

If you need more control, you can create a custom serializer class:

import 'dart:convert';
import 'package:shard/shard.dart';

class TodoListSerializer implements StateSerializer<List<Todo>> {
  @override
  String serialize(List<Todo> data) {
    return jsonEncode(data.map((t) => t.toJson()).toList());
  }
  
  @override
  List<Todo> deserialize(String data) {
    final json = jsonDecode(data) as List<dynamic>;
    return json.map((t) => Todo.fromJson(t as Map<String, dynamic>)).toList();
  }
}

Built-in Serializers

Shard provides built-in serializers for primitive types:

SerializerTypeExample
IntSerializerintCounter values
DoubleSerializerdoubleTemperature, prices
BoolSerializerboolDark mode, toggles
StringSerializerStringUsername, tokens
// Integer state
final counterSerializer = IntSerializer();

// Boolean state
final darkModeSerializer = BoolSerializer();

// Double state
final temperatureSerializer = DoubleSerializer();

// String state
final usernameSerializer = StringSerializer();

Configuration Options

autoSave

Automatically save data when state changes (default: true):

PersistentShard(
  initialState,
  autoSave: true, // Save automatically on state changes
)

autoLoad

Automatically load data when shard is created (default: true):

PersistentShard(
  initialState,
  autoLoad: true, // Load saved data on init
)

debounceDuration

Debounce duration for auto-save (default: 500ms):

PersistentShard(
  initialState,
  debounceDuration: const Duration(milliseconds: 1000), // Save after 1 second of inactivity
)

version

Schema version written into the persistence envelope (default: 1). Bump it when the shape of your persisted payload changes. See Schema versioning & migration for details.

PersistentShard(
  initialState,
  version: 2, // current schema version on disk
)

migrate

String Function(int fromVersion, String payload)? (default: null). Migrates an older stored payload up to the current version. Called once on load, only when the stored version is older than version. There is an assert that migrate requires version > 1. See Schema versioning & migration.

flushOnPause

When true, a WidgetsBindingObserver flushes a save on AppLifecycleState.paused/detached (default: false). See Flush on pause.

PersistentShard(
  initialState,
  flushOnPause: true,
)

version, migrate, and flushOnPause are also available on enablePersistence, PersistentShard, and SimplePersistentShard.

Manual Control

Save State Manually

final shard = TodoShard();
await shard.saveState();

Load State Manually

final shard = TodoShard();
final loaded = await shard.loadState();
if (loaded) {
  print('Load operation completed');
}

Clearing persisted state

Use clearPersistence() to delete this shard's persisted slice, for example on logout or when wiping local data. Because emit is @protected, expose a method on the shard that clears persistence and resets the in-memory state together:

class MyShard extends SimplePersistentShard<MyState> {
  // ... constructor, persistenceKey, serializer ...

  // On logout / wipe: delete this shard's persisted slice.
  Future<void> clearAll() async {
    await clearPersistence();
    emit(MyState.initial()); // clearPersistence does NOT reset in-memory state
  }
}

clearPersistence() cancels any pending debounced save, then deletes the stored value via StateStorage.delete. The delete is chained onto the save queue so it can't race a queued write. A later loadState() then calls onLoadComplete(null), as if storage was empty. Delete failures route to onSaveError.

It does not change the in-memory state, which is why the example pairs it with emit(MyState.initial()) to also reset the live state.

If you intend to dispose the shard right after clearing, call disablePersistence() first — otherwise the disposal flush re-saves the current in-memory state and recreates the key you just deleted.

Schema versioning & migration

When the shape of your persisted payload changes, bump version and provide a migrate function to upgrade older stored data:

class UserShard extends SimplePersistentShard<UserState> {
  UserShard()
      : super(
          UserState.empty(),
          storageFactory: () => SharedPreferencesStorage.create(),
          serializer: userSerializer,
          version: 2,
          migrate: (fromVersion, payload) {
            // payload is the raw serialized string at `fromVersion`.
            // Return it upgraded to the current `version`. You own chaining
            // (e.g. 1 -> 2 -> 3) when fromVersion is several steps behind.
            final json = jsonDecode(payload) as Map<String, dynamic>;
            json['displayName'] ??= json.remove('name');
            return jsonEncode(json);
          },
        );

  @override
  String get persistenceKey => 'user';
}

Persisted values are stored as a version envelope: {"__shard_v": N, "__shard_p": "<payload>"}. Reads are legacy-tolerant — a bare value written by 1.x is treated as version 1 — so upgrading keeps existing data (it is rewritten in envelope form on the next save).

migrate is called once with the stored version, and only when that version is older than the current version. It owns chaining intermediate migrations (e.g. 1 -> 2 -> 3) when the stored payload is several steps behind. The same version/migrate parameters exist on enablePersistence, PersistentShard, and SimplePersistentShard. (There is an assert that migrate requires version > 1.)

Upgrading from 1.x is safe — existing data is read as version 1. The only caveat is a downgrade: a 1.x build cannot read data written by 2.0 (the version envelope). Account for this if you ship a rollback.

Flush on pause

By default, auto-save is debounced, so a change made just before the app is backgrounded can be lost if the app is killed inside the debounce window. Set flushOnPause: true to flush a save when the app is paused or detached:

PersistentShard(
  initialState,
  storageFactory: () => SharedPreferencesStorage.create(),
  serializer: serializer,
  flushOnPause: true, // save on AppLifecycleState.paused/detached
);

When true, a WidgetsBindingObserver flushes a save on AppLifecycleState.paused/detached, so a backgrounded app doesn't lose the last change inside the debounce window. The default is false. The observer is removed on disablePersistence/dispose.

Error Handling

PersistentShard provides error callbacks that are automatically called when persistence operations fail. These callbacks also notify observers via addError, ensuring your global error handling is aware of persistence issues.

onLoadError

Called when loading data from storage fails. The shard continues with the initial state, so your app doesn't crash.

Behavior:

  • Shard continues with initialState (the state passed to constructor)
  • Error is automatically reported to observers via addError
  • You can override to add custom handling (analytics, user notifications, etc.)
class TodoShard extends PersistentShard<TodoState, List<Todo>> {
  // ... constructor ...
  
  @override
  void onLoadError(Object error, StackTrace? stackTrace) {
    // Always call super to notify observers
    super.onLoadError(error, stackTrace);
    
    // Update state to show error
    emit(state.copyWith(status: TodoStatus.error));
    
    // Add your custom error handling
    analytics.logError('load_state_failed', error);
  }
}

onSaveError

Called when saving data to storage fails. The in-memory state remains updated, so your app continues normally.

Behavior:

  • In-memory state is already updated (the emit call succeeded)
  • Only the storage write failed
  • Error is automatically reported to observers via addError
  • You can override to add retry logic or user notifications
class TodoShard extends PersistentShard<TodoState, List<Todo>> {
  // ... constructor ...
  
  @override
  void onSaveError(Object error, StackTrace? stackTrace) {
    // Always call super to notify observers
    super.onSaveError(error, stackTrace);
    
    // Add your custom error handling
    showSnackBar('Failed to save. Changes are kept in memory.');
  }
}

Retry Logic

Use retry() to retry loading data:

final shard = TodoShard();
try {
  await shard.loadState();
} catch (e) {
  // Retry after delay
  await Future.delayed(Duration(seconds: 1));
  await shard.retry();
}

StatePersistenceMixin

You can also add persistence to existing shards using the mixin:

class CustomShard extends Shard<MyState> with StatePersistenceMixin<MyState, MyData> {
  CustomShard() : super(MyState());
  
  @override
  void onInit() {
    super.onInit();
    enablePersistence(
      key: 'my_state',
      storage: myStorage,
      serializer: MyDataSerializer(),
      toPersistence: (state) => state.data,
      onLoadComplete: (data) => emit(state.copyWith(
        status: LoadStatus.loaded,
        data: data ?? defaultData,
      )),
    );
  }
}

Next Steps

Next: Todo App to see PersistentShard in action. See also: Infinite Scroll for throttle and pagination.