State management
Sharing state across native code and one or more Flutter engines.
Inlay provides a shared key-value storage layer that stays synchronized across all Flutter
engines and native code. Any change made from one place is automatically broadcast to all other
consumers. On the Flutter side, you can use the generated store directly or pair it with the
optional InlayCubit helper for a reactive UI pattern. Native code (iOS/Android) accesses the
same data through a NativeStorageScope.
Each Flutter engine runs in its own Dart isolate (its own memory), which is why cross-engine synchronization goes through the platform layer rather than shared memory. See Core concepts for the isolate model.
Architecture overview
All reads and writes go through an in-memory dictionary on the platform side. When any consumer writes a value, all other consumers are notified. The one exception is the originating writer, which doesn't receive its own notification - this prevents update loops.
Defining stores
Stores are plain Dart classes with annotations. The code generator produces typed wrappers with getters, setters, reactive streams, and snapshot support.
@InlayStore(key: 'sounds_notifications')
class SoundsNotificationsStore {
const SoundsNotificationsStore({
@InlayStoreKey() required this.contactId,
this.mute = false,
this.showPreviews = true,
this.sound = 'Default',
this.vibration = VibrationLevel.normal,
});
final String contactId;
final bool mute;
final bool showPreviews;
final String sound;
final VibrationLevel vibration;
}Annotations
| Annotation | Purpose |
|---|---|
@InlayStore(key: 'prefix') | Marks a class as a store definition. The key sets the prefix used in storage keys. |
@InlayStoreKey() | Marks a field as a key segment - it becomes part of the storage path to scope data, but is not stored as a value itself. |
Supported field types
Store value fields support:
- primitives:
bool,int,double,String - enums
- complex types:
List<T>,Map<K,V>, data classes, and nested combinations of these
Primitives are stored as plain strings. Enums are stored by index (0, 1, ...), not by
name, on every platform - which is why the order of enum values is part of the schema fingerprint.
Complex types are JSON-encoded into a single key-value entry, reusing the same serialization
pipeline as routes.
Non-nullable complex fields must have a default value (e.g. this.).
Nullable complex fields default to null when unset.
Storage keys
Values are stored under keys following the pattern {prefix}/:
sounds_notifications/abc-123/mute
sounds_notifications/abc-123/showPreviews
sounds_notifications/abc-123/soundThis means different contacts (or any scoped entity) each get their own set of keys.
Generated output
For the store above, the generator produces:
SoundsNotificationsStore(Dart) - a wrapper with typed async getters/setters (e.g.getMute(),setMute(bool)), a reactivestream, and snapshot support.SoundsNotificationsStoreSnapshot(Dart) - an immutable data class holding all field values, with acopyWithmethod.
On top of that, typed store wrappers are generated for Swift and Kotlin (see native usage).
Using stores from Flutter
Direct store usage
You can use the generated store directly - no Cubit required:
final store = SoundsNotificationsStore(
KeyValueStorage.instance,
contactId: 'abc-123',
);
// Read
final isMuted = await store.getMute();
// Write
await store.setMute(true);
// Read all fields at once
final snapshot = await store.getSnapshot();
print('Sound: ${snapshot.sound}, Muted: ${snapshot.mute}');
// React to changes from other engines or native code
store.stream.listen((snapshot) {
print('External change: mute=${snapshot.mute}');
});Using with InlayCubit (optional)
InlayCubit is an optional convenience layer built on the bloc
library. It connects a store to a reactive UI pattern by:
- Loading the initial snapshot from the store automatically.
- Subscribing to the store's
streamfor cross-engine sync. - Persisting changes to the store on every state change. Primitive and enum fields are written only when they changed; complex fields (lists, maps, data classes) are rewritten on every change.
After construction, call init() to load the initial snapshot and start listening for
cross-engine updates. The idiomatic way is Dart's cascade operator: MyCubit(store)..
If your team already uses bloc/Cubit in Flutter, this is a natural fit. If not, skip it entirely and use the store directly - the store and storage layers work independently.
Let's define a Cubit first:
class SoundsNotificationsCubit
extends InlayCubit<SoundsNotificationsStoreSnapshot> {
SoundsNotificationsCubit(super.store);
void toggleMute() {
if (state case InlayStateReady(data: final current)) {
emit(InlayStateReady(current.copyWith(mute: !current.mute)));
}
}
void changeSound(String sound) {
if (state case InlayStateReady(data: final current)) {
emit(InlayStateReady(current.copyWith(sound: sound)));
}
}
}Then we can use it in a widget:
class SoundsNotificationsScreen extends StatelessWidget {
const SoundsNotificationsScreen({super.key, required this.contactId});
final String contactId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => SoundsNotificationsCubit(
SoundsNotificationsStore(
KeyValueStorage.instance,
contactId: contactId,
),
)..init(),
child: BlocBuilder<SoundsNotificationsCubit,
InlayState<SoundsNotificationsStoreSnapshot>>(
builder: (context, state) => switch (state) {
InlayStateLoading() =>
const Center(child: CircularProgressIndicator()),
InlayStateReady(:final data) => ListView(
children: [
SwitchListTile(
title: const Text('Mute'),
value: data.mute,
onChanged: (_) =>
context.read<SoundsNotificationsCubit>().toggleMute(),
),
],
),
},
),
);
}
}InlayState is a sealed type with two variants:
| Variant | Description |
|---|---|
InlayStateLoading | The initial snapshot is still being loaded from the store. |
InlayStateReady(data) | The snapshot is loaded and ready. |
If you need to observe multiple stores in a single Cubit, use the InlayStoreObserver mixin.
Using stores from native code
Native code accesses the same storage through a NativeStorageScope. You create a scope,
read/write values, and optionally observe changes. The generator produces typed store wrappers
for both Swift and Kotlin, with typed properties and a containsChanges helper for filtering
observer callbacks, so there is no need to hardcode any key strings.
let scope = KeyValueStorageImpl.shared.createScope()
let store = SoundsNotificationsStore(storage: scope, contactId: "abc-123")
// Read (typed)
let isMuted = store.mute
// Write (automatically broadcast to Flutter engines and other scopes)
store.mute = true
// Observe changes made by Flutter or other native scopes
scope.startObserving { entries in
if store.containsChanges(in: entries) {
print("mute = \(store.mute), sound = \(store.sound)")
}
}
// Stop observing and clean up
scope.stopObserving()
scope.dispose()KeyValueStorage (low-level Dart API)
KeyValueStorage is the Dart singleton underneath the generated stores. Use it directly for
ad-hoc values that don't warrant a full store definition. Initialize once per engine before
runApp:
WidgetsFlutterBinding.ensureInitialized();
await KeyValueStorage.instance.init();API
// String
await KeyValueStorage.instance.putString('key', 'value');
final value = await KeyValueStorage.instance.getString('key');
// Bool
await KeyValueStorage.instance.putBool('key', value: true);
final flag = await KeyValueStorage.instance.getBool('key');
// JSON
await KeyValueStorage.instance.putJson('key', {'nested': 'data'});
final json = await KeyValueStorage.instance.getJson('key');
// Bulk operations
await KeyValueStorage.instance.putAll([
StorageEntry(key: 'a', value: '1'),
StorageEntry(key: 'b', value: '2'),
]);
final entries = await KeyValueStorage.instance.getByPrefix('sounds_');
// Reactive stream - emits when OTHER writers change values, plus one
// full-store event when the app returns to the foreground
KeyValueStorage.instance.stream.listen((entries) {
// handle external changes
});Cross-engine synchronization
Here's what happens when a value changes:
- A consumer (Flutter store, Cubit, or native scope) writes a value. The write goes through the platform via a Pigeon channel.
- The platform-side in-memory store updates and notifies all registered consumers except the writer.
- Each other Flutter engine's
KeyValueStorage.emits the change. If you use a generated store, itsstream streamfilters for relevant keys and emits a new snapshot. If you useInlayCubit, the Cubit updates its state automatically. - Registered
NativeStorageScopeobservers receive the change via their callback.
Thread safety
| Concern | Solution |
|---|---|
| Platform store (iOS) | Concurrent DispatchQueue with barrier writes |
| Platform store (Android) | Read-write lock, so batch writes are atomic against snapshot reads (matching iOS); synchronized observer registries |
| Dart isolates | Each engine is its own isolate; updates are delivered asynchronously via streams |
| Self-notification | Each writer is tagged with an ID; notifications skip the originator |
| Conflicts | Last-write-wins; writeSnapshot diffs primitive and enum fields against the previous state and writes only the changed ones (complex fields are always written) |
| App backgrounding | KeyValueStorage performs a full sync on AppLifecycleState. to catch changes made while the engine was inactive |
Theme and locale propagation
An engine per native container means each container starts blank - a Flutter screen pushed
from native can't inherit app-level settings like dark mode or an in-app language override from
another container's widget state. InlayAppearance
keeps both in the storage layer described above, so every engine (including ones created
later) reads the current values at startup and follows changes live.
The native host drives the settings with a typed API:
InlayAppearance.shared.themeMode = .dark
InlayAppearance.shared.localeLanguageTag = "pl-PL"
InlayAppearance.shared.localeLanguageTag = nil // follow the system againThe Dart entrypoint initializes it after storage and rebuilds the app on change:
await KeyValueStorage.instance.init();
await InlayAppearance.instance.init();
runApp(
ListenableBuilder(
listenable: InlayAppearance.instance,
builder: (context, _) => MaterialApp.router(
themeMode: InlayAppearance.instance.themeMode,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
locale: InlayAppearance.instance.locale,
// ...
),
),
);Flutter code can also change the settings (InlayAppearance.,
setLocale(.); the change reaches all other engines and native scopes the same way.
Dialogs launched via runInlayDialog follow InlayAppearance automatically unless you
pass an explicit themeMode.
System dark mode already propagates to Flutter on its own - the engine follows the OS
setting. InlayAppearance is for the in-app override case: the host app has its own
theme toggle or language picker, and every Flutter screen must follow it.
When to use what
| Approach | Type safety | Cross-engine sync | Best for |
|---|---|---|---|
@InlayStore + InlayCubit | Full (typed fields, snapshots) | Automatic | Flutter screens that need reactive state tied to storage |
@InlayStore (direct) | Full | Manual (listen to stream) | Flutter code that doesn't use bloc |
KeyValueStorage (raw) | Manual (string keys/values) | Manual (listen to stream) | Ad-hoc values, one-off reads/writes |
NativeStorageScope + generated store | Full (typed properties) | Via startObserving + containsChanges | Native code (iOS/Android) |