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

AnnotationPurpose
@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.tags = const []). Nullable complex fields default to null when unset.

Storage keys

Values are stored under keys following the pattern {prefix}/{keyField}/{fieldName}:

sounds_notifications/abc-123/mute
sounds_notifications/abc-123/showPreviews
sounds_notifications/abc-123/sound

This 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 reactive stream, and snapshot support.
  • SoundsNotificationsStoreSnapshot (Dart) - an immutable data class holding all field values, with a copyWith method.

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:

  1. Loading the initial snapshot from the store automatically.
  2. Subscribing to the store's stream for cross-engine sync.
  3. 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)..init().

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:

VariantDescription
InlayStateLoadingThe 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:

  1. A consumer (Flutter store, Cubit, or native scope) writes a value. The write goes through the platform via a Pigeon channel.
  2. The platform-side in-memory store updates and notifies all registered consumers except the writer.
  3. Each other Flutter engine's KeyValueStorage.stream emits the change. If you use a generated store, its stream filters for relevant keys and emits a new snapshot. If you use InlayCubit, the Cubit updates its state automatically.
  4. Registered NativeStorageScope observers receive the change via their callback.

Thread safety

ConcernSolution
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 isolatesEach engine is its own isolate; updates are delivered asynchronously via streams
Self-notificationEach writer is tagged with an ID; notifications skip the originator
ConflictsLast-write-wins; writeSnapshot diffs primitive and enum fields against the previous state and writes only the changed ones (complex fields are always written)
App backgroundingKeyValueStorage performs a full sync on AppLifecycleState.resumed 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 again

The 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.instance.setThemeMode(...), 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

ApproachType safetyCross-engine syncBest for
@InlayStore + InlayCubitFull (typed fields, snapshots)AutomaticFlutter screens that need reactive state tied to storage
@InlayStore (direct)FullManual (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 storeFull (typed properties)Via startObserving + containsChangesNative code (iOS/Android)

On this page