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.
| Use Case | Class |
|---|---|
| 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 logic | PersistentShard<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).
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.
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);
storageFactory instead. See State Storage section for details.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;
Called when load operation completes. The data parameter is:
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 ?? [],
));
}
StateStorage is an interface for storage backends. You need to implement it for your storage solution.
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.
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);
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();
storage: When you have the storage instance ready before creating the shard, or when you want to share the same storage instance across multiple shardsstorageFactory: When storage initialization is async (like SharedPreferences, Hive, etc.) and you want the shard to handle initialization automaticallyimport '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.
StateSerializer<K> converts the persistence data to/from strings for storage. Note that it serializes K (the persistence type), not T (the full state).
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(),
);
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 ?? []));
}
}
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();
}
}
Shard provides built-in serializers for primitive types:
| Serializer | Type | Example |
|---|---|---|
IntSerializer | int | Counter values |
DoubleSerializer | double | Temperature, prices |
BoolSerializer | bool | Dark mode, toggles |
StringSerializer | String | Username, tokens |
// Integer state
final counterSerializer = IntSerializer();
// Boolean state
final darkModeSerializer = BoolSerializer();
// Double state
final temperatureSerializer = DoubleSerializer();
// String state
final usernameSerializer = StringSerializer();
Automatically save data when state changes (default: true):
PersistentShard(
initialState,
autoSave: true, // Save automatically on state changes
)
Automatically load data when shard is created (default: true):
PersistentShard(
initialState,
autoLoad: true, // Load saved data on init
)
Debounce duration for auto-save (default: 500ms):
PersistentShard(
initialState,
debounceDuration: const Duration(milliseconds: 1000), // Save after 1 second of inactivity
)
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
)
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.
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.
final shard = TodoShard();
await shard.saveState();
final shard = TodoShard();
final loaded = await shard.loadState();
if (loaded) {
print('Load operation completed');
}
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.
disablePersistence() first — otherwise the disposal flush re-saves the current in-memory state and recreates the key you just deleted.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.)
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.
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.
Called when loading data from storage fails. The shard continues with the initial state, so your app doesn't crash.
Behavior:
initialState (the state passed to constructor)addErrorclass 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);
}
}
Called when saving data to storage fails. The in-memory state remains updated, so your app continues normally.
Behavior:
emit call succeeded)addErrorclass 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.');
}
}
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();
}
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: Todo App to see PersistentShard in action. See also: Infinite Scroll for throttle and pagination.