hydrated_mobx 1.2.0
hydrated_mobx: ^1.2.0 copied to clipboard
An extension to the MobX state management library which automatically persists and restores MobX states.
HydratedMobX #
A Flutter package that automatically persists and restores MobX stores. Built to work with Flutter's state management solution MobX.
This package uses some code from hydrated_bloc by Felix Angelov, which is licensed under the MIT License. We extend our gratitude to the original authors for their work.
Features #
- Automatically persists and restores MobX stores
- Supports encryption for secure storage
- Works on all platforms (iOS, Android, Web, Linux, macOS, Windows)
- Built on top of Hive for fast and efficient storage
- Schema versioning with a
migratehook for evolving persisted state - Import existing data from another persistence layer via
importData - Simple and intuitive API
Getting started #
Add the package to your pubspec.yaml:
dependencies:
hydrated_mobx: ^1.2.0
Usage #
- Initialize HydratedMobX in your main.dart:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hydrated_mobx/hydrated_mobx.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final appDocumentDir = await getApplicationDocumentsDirectory();
HydratedMobX.storage = await HydratedStorage.build(
storageDirectory: HydratedStorageDirectory(appDocumentDir.path),
);
runApp(App());
}
- Create a hydrated store:
import 'package:mobx/mobx.dart';
import 'package:hydrated_mobx/hydrated_mobx.dart';
part 'counter_store.g.dart';
class CounterStore = _CounterStore with _$CounterStore;
abstract class _CounterStore extends HydratedMobX with Store {
@observable
int count = 0;
@action
void increment() => count++;
@override
Map<String, dynamic>? toJson() => {'count': count};
@override
void fromJson(Map<String, dynamic> json) {
count = json['count'] as int;
}
}
Overriding the storage key (per-instance id) #
When you have multiple instances of the same store (e.g. one per meeting or per user), pass the id to super(storeId: ...) so hydration uses the correct key. This works with dependency injection (e.g. injectable):
abstract class _MeetingStoreBase extends HydratedMobX with Store {
_MeetingStoreBase(
DioService dioService,
MeetingsStore meetingsStore,
@factoryParam String meetingId,
) : _meetingId = meetingId,
super(storeId: meetingId);
final String _meetingId;
@override
String get id => _meetingId;
// ...
}
See the example app’s KeyedCounterStore for a minimal example.
Type-safe fromJson / toJson with HydratedJson #
To avoid manual type checks and try/catch in fromJson/toJson, use the HydratedJson helpers:
import 'package:hydrated_mobx/hydrated_mobx.dart';
import 'package:mobx/mobx.dart';
@override
void fromJson(Map<String, dynamic> json) {
_meetings = HydratedJson.readList(
json,
'meetings',
MeetingDto.fromJson,
).asObservable();
_meta = HydratedJson.readObject(json, 'meta', MetaDto.fromJson) ??
MetaDto(take: 20);
}
@override
Map<String, dynamic> toJson() => {
'meetings': HydratedJson.writeList(_meetings, (e) => e.toJson()),
'meta': _meta.toJson(),
};
Available helpers: readList, readObject, readString, readInt, readDouble, readBool, writeList. They return safe defaults (e.g. empty list, 0, null) when the key is missing or the value has the wrong type.
Migrating persisted state across schema changes #
When the shape of your persisted state changes between app releases, bump
version and implement migrate to upgrade older data. migrate is called
during hydration whenever the stored version is lower than the current one; its
result is passed to fromJson and re-persisted under the new version, so it runs
only once per upgrade. Data written before versioning existed is treated as
version 1.
class CounterStore extends HydratedMobX with Store {
CounterStore() { hydrate(); }
final Observable<int> _count = Observable(0);
int get count => _count.value;
@override
int get version => 2;
@override
Map<String, dynamic> migrate(int oldVersion, Map<String, dynamic> old) {
if (oldVersion < 2) {
// v1 stored the value under 'counter'; v2 renamed it to 'count'.
old['count'] = old.remove('counter') ?? 0;
}
return old;
}
@override
Map<String, dynamic>? toJson() => {'count': _count.value};
@override
void fromJson(Map<String, dynamic> json) =>
_count.value = (json['count'] as int?) ?? 0;
}
When jumping multiple versions at once, apply every intermediate step in order
with stacked if (oldVersion < N) blocks. Dart switch cases do not fall
through, so a switch would skip the intermediate upgrades.
Importing existing data #
To bring data in from another persistence layer — SharedPreferences, a legacy
Hive box, or a previous key scheme — seed the storage with
HydratedMobX.importData after storage is set and before you construct the
stores that should pick it up. Keys map to each store's storageToken,
composed as '$storagePrefix${storeId ?? id}' (include the storeId/id
component when the target store overrides it, and note that the default
storagePrefix is runtimeType, which is not stable under --obfuscate —
override storagePrefix on such stores so the key matches). Existing keys are
left untouched by default; pass overwrite: true to replace them.
If the store uses schema versioning (version > 1), pass the version of the
data you are importing so it is not needlessly run through migrate; leave it
at the default (1) for legacy data that should be migrated forward.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedMobX.storage = await HydratedStorage.build(
storageDirectory: HydratedStorageDirectory(
(await getApplicationDocumentsDirectory()).path,
),
);
final prefs = await SharedPreferences.getInstance();
await HydratedMobX.importData({
'CounterStore': {'count': prefs.getInt('count') ?? 0},
});
final store = CounterStore(); // hydrates from the imported data
runApp(App());
}
Observing persistence failures #
State is persisted in the background (fire-and-forget), so a failing write never
interrupts your store. To observe such failures — a full disk, an encryption
error — pass an onStorageError handler. It defaults to logging the error.
class CounterStore extends HydratedMobX with Store {
CounterStore()
: super(
onStorageError: (error, stackTrace) {
FirebaseCrashlytics.instance.recordError(error, stackTrace);
},
) {
hydrate();
}
// ...
}
Note on multiple instances: the default storage key is derived from the store's
runtimeType, so two live instances of the same store type share one key and overwrite each other. If you intentionally keep several instances of the same type, overrideid(or passstoreId) to give each a distinct key.
Clearing, resuming, and disposing #
clear()deletes the store's cached state and, by default, stops persisting further changes.clear(resume: true)deletes the cached state but keeps persisting, so subsequent changes are saved again.dispose()permanently stops persistence and removes the store from the internal registry so it can be garbage collected. Call it when the store is no longer used (e.g. from the owning widget'sdispose); the on-disk state is left untouched.
await store.clear(); // wipe cache, stop persisting
await store.clear(resume: true); // wipe cache, keep persisting
store.dispose(); // stop persisting for good, keep stored data
Call dispose() when a store is scoped to a widget. A persisting store is
kept alive by its internal MobX reaction (this is how it observes changes), so a
store you simply drop is not garbage-collected until that reaction is released.
Calling dispose() releases it immediately. For a store owned by a State,
dispose it from the widget's dispose:
class _MyPageState extends State<MyPage> {
final store = CounterStore();
@override
void dispose() {
store.dispose();
super.dispose();
}
// ...
}
Long-lived, app-wide singleton stores don't need this — they live for the whole session by design.
Additional information #
- For more information about MobX, visit the MobX documentation
- For issues and feature requests, please visit the GitHub repository
- Contributions are welcome! Feel free to submit pull requests or open issues