# State Management Bridges

**State management owns presentation state. Plugin Kit owns participation.** A chat screen showing messages is presentation state; a plugin deciding whether it wants to enrich an outgoing prompt is participation. The bridges below let participation flow through whichever widget-rebuild mechanism your state library already provides. Pick the recipe that matches the library you already use; the runtime side is identical.

Plugin Kit is library-agnostic about state management, but the bus, registry, and session lifecycle overlap with what most state libraries do. You can wire it through whatever your app already uses, or lean on the bus directly when an extra layer would only forward events. This page shows the wire-up between a `PluginSession` and seven common state holders, with the same chat protocol used in every example so you can read the differences side by side and pick the cost you actually want to pay.
**Source of citation:** Every recipe on this page is implemented in [`example/state_garden/`](https://github.com/SaadArdati/plugin_kit/tree/main/example/state_garden) and runnable in your browser at [plugin-kit.saad-ardati.dev/state-garden](https://plugin-kit.saad-ardati.dev/state-garden). Seventeen tests in that package compile, run, and assert the recipes against a real `PluginRuntime`, including the two `flutter_plugin_kit` variants shown inline below. If a recipe here drifts from what actually works, the test in that package fails on the next commit.
**The flutter_plugin_kit variant:** Two of the recipes below (`StatefulWidget + setState` and `ChangeNotifier + provider`) drop most of the bookkeeping (subscription field, `_disposed` flag, manual `cancel()` in `dispose`) once you pull in [`flutter_plugin_kit`](https://plugin-kit.saad-ardati.dev/guides/flutter-plugin-kit/). Line counts come out roughly similar; what shrinks is the cognitive load: the package ships a `PluginSessionStateListener` mixin that owns the subscription lifecycle for `State<W>`, plus a `PluginEventNotifier<E>` that already *is* a `ChangeNotifier` exposing the latest event as `.value`. Each affected recipe shows the package variant inline. The remaining recipes (Bloc, Riverpod, signals, MobX, GetIt) keep their library-native shape; `flutter_plugin_kit` does not displace state libraries, it just removes the bookkeeping layer for hosts that *are* a `State` or a `ChangeNotifier`.

## The shape every recipe shares

A state holder bridge has the same three duties no matter which library you pick.

1. Subscribe to the relevant fact event on the session bus (`ChatMessagesChanged` in these recipes), store the resulting `EventSubscription` in a field, and update the holder's state inside the handler.
2. Translate UI intents into bus events with `session.emit(SomeIntent(...))`.
3. Cancel the subscription in whatever disposal hook the library provides: `Cubit.close`, `ChangeNotifier.dispose`, `ref.onDispose`, `State.dispose`, etc.

The bridge does NOT cache resolved services. Most recipes also avoid subscribing inside `build`, and the Riverpod `AsyncNotifier` recipe is the explicit exception because it re-attaches when `build` re-runs. Both decisions matter and are explained below.

## Subscription ownership

`session.on(...)` is a thin pass-through to `EventBus.on`. The framework does NOT auto-cancel a subscription registered this way. That auto-tracking exists only on `Plugin.on(context, ...)` and `StatefulPluginService.on(...)`, which the framework cancels in `_runDetach` and `_unbindContext` respectively.

A state holder lives outside the plugin lifecycle, so it owns its subscription's lifetime. Forget to cancel and the handler keeps firing on the session bus until the bus itself is disposed. Cancel after the holder is dead and the holder may try to write to torn-down state.

The pattern in every recipe below is the same: store the subscription in a final field, cancel in the disposal hook, and guard the handler body so a fire that races the cancel is harmless.

## Post-dispose guards

Cancelling a subscription does not break out of an in-progress dispatch. `EventBus.emit` snapshots its handler list before iterating; a handler removed mid-cascade still runs once if the cascade had already started.

Each recipe guards against this by checking the holder's "is closed" flag before mutating state:

- `Cubit` checks `isClosed`.
- `State` checks `mounted`.
- `ChangeNotifier`, signals, MobX bridges flip a local `_disposed` flag in `dispose()` before cancelling.

Without the guard, a late fire that calls `notifyListeners` or `emit` on a dead holder throws.

## A shared chat protocol

Every recipe below uses the same plugin-side protocol so the differences are entirely about state-holder shape.

```dart
extractRegion(stateBridgesSource, 'state-bridge-chat-message-types')
```

The plugin's `ChatService` listens for `SendMessageRequested`, appends the user line and an `'echo: ' + text` bot line to its own list, and emits `ChatMessagesChanged` with the full snapshot. See [`chat_service.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/chat/chat_service.dart) for the actual code.
**Handler signatures are `EventEnvelope<T>`, not `T`:** Every recipe below calls `session.on<T>(...)` (or a `flutter_plugin_kit` helper that delegates to it). The handler receives an `EventEnvelope<T>`, not the payload directly. The payload lives at `envelope.event`. A handler written as `(event) { messages = event.messages; }` does not compile because `EventEnvelope` has no `.messages` getter. Always destructure: `(envelope) { messages = envelope.event.messages; }`.

The same shape applies to `EventBinding.on<T>` and `PluginSessionStateListener.listen<T>` / `rebuildOn<T>` from `flutter_plugin_kit`.

## Recipe: StatefulWidget + setState

The reference shape with no library at all. The widget owns the subscription. `mounted` guards every continuation.

```dart
extractRegion(setStateChatScreenSource, 'set-state-chat-screen-set-state-chat-screen')
```

Source: [`set_state_chat_screen.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/integrations/set_state_chat_screen.dart).

### With flutter_plugin_kit

If `flutter_plugin_kit` is in your pubspec, the same screen is the mixin call plus a `setState` inside the handler. The mixin owns the subscription field, the cancel-on-dispose, and the re-attach across session swaps. `listen` is callable from `initState` directly, with no `_wired` flag and no `didChangeDependencies` deferral.

```dart
extractRegion(stateBridgesSource, 'state-bridge-set-state')
```

If the session lives in an ambient `PluginSessionScope` instead of a `widget.session` parameter, drop the `session` override entirely; the mixin reads the scope by default.

## Recipe: ChangeNotifier + provider

A `ChangeNotifier` subclass holds a `List<ChatMessage>` and calls `notifyListeners` whenever the bus delivers a new snapshot. The `_disposed` flag is the post-dispose guard described above.

```dart
extractRegion(changeNotifierChatSource, 'change-notifier-chat-chat-change-notifier')
```

Wrap with `ChangeNotifierProvider` and read in widgets through `context.watch<ChatChangeNotifier>()` as usual. Source: [`change_notifier_chat.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/integrations/change_notifier_chat.dart).

### With flutter_plugin_kit

If you only need "the latest event of type `T`," `flutter_plugin_kit` ships `PluginEventNotifier<E>`: a `ChangeNotifier` / `ValueListenable<E?>` that already does the subscribe / store-latest / cancel-on-dispose dance. There is no custom subclass to write.

```dart
ChangeNotifierProvider(
  create: (context) => PluginEventNotifier<ChatMessagesChanged>(
    PluginSessionScope.of(context),
  ),
  child: const ChatBody(),
);

// In ChatBody:
final messages = context
        .watch<PluginEventNotifier<ChatMessagesChanged>>()
        .value
        ?.messages
    ?? const <ChatMessage>[];
```

For the same shape as a `ValueListenable`, swap `ChangeNotifierProvider` for `ValueListenableProvider` or pass it to a `ValueListenableBuilder<ChatMessagesChanged?>` directly. The `_messages` list, the `_disposed` flag, and the `notifyListeners` call all disappear because the foundation type does the work.

The hand-written `ChangeNotifier` subclass above stays the right shape when you have multiple events to fold into one state, custom derived state, or behavior the `latest E` shape doesn't capture.

## Recipe: flutter_bloc

A Cubit subscribes in its constructor and guards both the bus handler and the explicit `await` in `send` with `isClosed`.

```dart
extractRegion(blocChatSource, 'bloc-chat-chat-bloc-state')
```

`ChatBlocState` implements value equality so `BlocBuilder` does not rebuild when an identical snapshot is re-emitted. Source: [`bloc_chat.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/integrations/bloc_chat.dart).
**The Cubit-as-bridge is the most redundant of these recipes:** Read the body of `_onMessagesChanged`: it takes a bus event and re-emits it as widget state. That is exactly what a `setState` call inside a `StatefulWidget` listener would do off the same `session.on<ChatMessagesChanged>` subscription. Keep the Cubit if you already have one and value the `state.copyWith` ergonomics or `BlocBuilder`'s rebuild filtering. Those are real wins. If you don't, the bus is already a state-update channel and the Cubit is one bridge too many.

## Recipe: Riverpod

The recommended shape uses a `Provider<PluginSession>` that the host overrides at app boot, plus an `AsyncNotifier` that subscribes inside `build` and registers `ref.onDispose` after wiring the subscription.

```dart
extractRegion(riverpodChatSource, 'riverpod-chat-chat-notifier')
```

At app boot:

```dart
ProviderScope(
  overrides: [sessionProvider.overrideWithValue(holder.session)],
  child: MyApp(),
)
```

Source: [`riverpod_chat.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/integrations/riverpod_chat.dart).
**Do not create the session inside a FutureProvider:** A `FutureProvider` that calls `runtime.createSession()` inside its build leaks the previous session when the provider invalidates, because `ref.onDispose` is the only thing that would dispose it. Either register `ref.onDispose(() => session.dispose())` inside that provider, or do what the recipe above does and inject an already-built session via override. The override pattern is cleaner: it keeps session lifetime visible at the app boot site instead of buried in a Riverpod provider closure.

## Recipe: signals_flutter

A `Signal` holds the snapshot. The bus handler writes to `signal.value`. The `Watch` builder rebuilds when the signal is read inside it.

```dart
extractRegion(signalsChatSource, 'signals-chat-signals-chat-bridge')
```

Render with `Watch((context) => ChatView(messages: bridge.messages.value, ...))`. Source: [`signals_chat.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/integrations/signals_chat.dart).

## Recipe: MobX

Mirror the signals shape with `Observable` plus `runInAction`. No code generation needed.

```dart
extractRegion(mobxChatSource, 'mobx-chat-mobx-chat-bridge')
```

Render with `Observer(builder: (context) => ChatView(messages: bridge.messages.value, ...))`. Source: [`mobx_chat.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/integrations/mobx_chat.dart).

## Recipe: GetIt

GetIt is a service locator, not a state library. The host registers the active `PluginSession` at app boot; the screen looks it up by type. The State class then mirrors the setState recipe.

```dart
extractRegion(stateBridgesSource, 'state-bridge-get-it-screen')
```

Source: [`get_it_chat_screen.dart`](https://github.com/SaadArdati/plugin_kit/blob/main/example/state_garden/lib/src/integrations/get_it_chat_screen.dart).

## Things every recipe avoids

These come up often in early drafts. Each is a real failure mode, not a stylistic preference.

- **Caching `final svc = session.resolve<T>(...)` in a state holder.** A higher-priority registrant or a settings-driven priority change will not be visible to the cached reference. Resolve at point of use unless the resolved object is genuinely owned by the state holder.
- **Subscribing in `build`.** Every rebuild registers a new handler and nothing cancels them. Subscribe in the holder's constructor or in Riverpod's `build` (which IS a constructor-equivalent that runs once per holder lifetime).
- **Putting the runtime inside an `InheritedWidget`.** The runtime outlives the widget tree (route changes, hot restart, deep nav). Hold it outside the tree (top-level final, GetIt singleton, Riverpod Provider with single-shot create) and expose the session into the tree.
- **Calling both `session.dispose()` and `runtime.dispose()`.** `runtime.dispose()` already iterates and disposes live sessions. Calling both races on stateful service detach. One disposal call: the runtime.
- **Treating the handler parameter as the payload.** `session.on<T>` calls back with `EventEnvelope<T>`. The payload is at `envelope.event`.

## Lifecycle hazards worth knowing

These show up rarely but bite hard.

- **Settings reconciliation does not dispose the session.** `updateSessionSettings` mutates the registry in place and runs detach/attach for plugins whose enablement changed. The session bus, registry, and context object are the same instances afterward. Existing subscriptions on the session bus survive.
- **Disabling a plugin removes its event handlers.** A consumer's `session.emit(SomeIntent())` reaches no handler if the plugin that owned the `on<SomeIntent>` was just disabled. No error: the cascade simply has no handlers to run.
- **Concurrent settings updates throw, so toggle handlers need to be serialized.** Starting a second `updateSettings` / `updateGlobalSettings` / `updateSessionSettings` while one is already in flight raises a `StateError` from `_enterReconcile` rather than corrupting registry state. Tail-chain your toggle handlers (`_pending = _pending.then(...)`) so a rapid double-tap on a settings toggle queues instead of throwing.
- **Hot-swap by priority works through resolve, not through events.** Disabling a higher-priority registrant for a slot makes the lower-priority one win on the next `session.resolve<T>(...)`. If your code holds the resolved instance in a field, it does not see the swap. Resolve at point of use.

## Related reading

[Migrating a Flutter App](https://plugin-kit.saad-ardati.dev/guides/migrating-flutter-app/)
  [Flutter Integration](https://plugin-kit.saad-ardati.dev/guides/flutter-integration/)
  [Settings & Overrides](https://plugin-kit.saad-ardati.dev/guides/settings/)
  [Sessions](https://plugin-kit.saad-ardati.dev/concepts/sessions/)