Skip to content

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.

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.

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.

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.

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

/// Standalone event types used in bridge examples.
void showChatTypes() {
const msg = ChatMessage(author: 'user', text: 'hello');
const changed = ChatMessagesChanged(messages: [msg]);
const requested = SendMessageRequested(text: 'hello');
print('${msg.author}: ${changed.messages.length} ${requested.text}');
}

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 for the actual code.

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

class SetStateChatScreen extends StatefulWidget {
const SetStateChatScreen({super.key, required this.session});
final PluginSession session;
@override
State<SetStateChatScreen> createState() => _SetStateChatScreenState();
}

Source: set_state_chat_screen.dart.

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.

class _SetStateChatScreenState extends State<SetStateChatScreen>
with PluginSessionStateListener<SetStateChatScreen> {
@override
PluginSession? get session => widget.session;
List<ChatMessage> _messages = const <ChatMessage>[];
@override
void initState() {
super.initState();
listen<ChatMessagesChanged>((envelope) {
if (!mounted) return;
setState(() => _messages = envelope.event.messages);
});
}
Future<void> _onSubmit(String text) =>
widget.session.emit(SendMessageRequested(text: text));
@override
Widget build(BuildContext context) =>
ChatView(title: 'setState', messages: _messages, onSend: _onSubmit);
}

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.

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.

class ChatChangeNotifier extends ChangeNotifier with PluginSessionListener {
ChatChangeNotifier(this._session) {
attachSubscriptions();
}
final PluginSession _session;
@override
PluginSession get session => _session;
@override
List<EventBinding> get subscriptions => [
on<ChatMessagesChanged>(_onMessagesChanged),
];
bool _disposed = false;
List<ChatMessage> _messages = const <ChatMessage>[];
List<ChatMessage> get messages => _messages;
Future<void> send(String text) => _session.emit(SendMessageRequested(text));
void _onMessagesChanged(EventEnvelope<ChatMessagesChanged> envelope) {
if (_disposed) return;
_messages = envelope.event.messages;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
detachSubscriptions();
super.dispose();
}
}

Wrap with ChangeNotifierProvider and read in widgets through context.watch<ChatChangeNotifier>() as usual. Source: change_notifier_chat.dart.

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.

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.

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

class ChatBlocState {
const ChatBlocState({
this.messages = const <ChatMessage>[],
this.sending = false,
});
final List<ChatMessage> messages;
final bool sending;
ChatBlocState copyWith({List<ChatMessage>? messages, bool? sending}) {
return ChatBlocState(
messages: messages ?? this.messages,
sending: sending ?? this.sending,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChatBlocState &&
runtimeType == other.runtimeType &&
sending == other.sending &&
_messagesEquality.equals(messages, other.messages);
@override
int get hashCode => Object.hash(sending, _messagesEquality.hash(messages));
}

ChatBlocState implements value equality so BlocBuilder does not rebuild when an identical snapshot is re-emitted. Source: bloc_chat.dart.

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.

class ChatNotifier extends AsyncNotifier<List<ChatMessage>>
with PluginSessionListener {
bool _disposed = false;
late PluginSession _session;
@override
PluginSession get session => _session;
@override
List<EventBinding> get subscriptions => [
on<ChatMessagesChanged>(_onMessagesChanged),
];
@override
Future<List<ChatMessage>> build() async {
_disposed = false;
_session = ref.watch(sessionProvider);
detachSubscriptions();
attachSubscriptions();
ref.onDispose(() {
_disposed = true;
detachSubscriptions();
});
return const <ChatMessage>[];
}
void _onMessagesChanged(EventEnvelope<ChatMessagesChanged> envelope) {
if (_disposed) return;
state = AsyncData<List<ChatMessage>>(envelope.event.messages);
}
Future<void> send(String text) async {
final PluginSession session = ref.read(sessionProvider);
await session.emit(SendMessageRequested(text));
}
}

At app boot:

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

Source: riverpod_chat.dart.

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

class SignalsChatBridge with PluginSessionListener {
SignalsChatBridge(this._session) {
attachSubscriptions();
}
final PluginSession _session;
@override
PluginSession get session => _session;
@override
List<EventBinding> get subscriptions => [
on<ChatMessagesChanged>(_onMessagesChanged),
];
bool _disposed = false;
final Signal<List<ChatMessage>> messages = signal(const <ChatMessage>[]);
Future<void> send(String text) =>
_session.emit(SendMessageRequested(text)).then((_) {});
void _onMessagesChanged(EventEnvelope<ChatMessagesChanged> envelope) {
if (_disposed) return;
messages.value = envelope.event.messages;
}
void dispose() {
_disposed = true;
detachSubscriptions();
}
}

Render with Watch((context) => ChatView(messages: bridge.messages.value, ...)). Source: signals_chat.dart.

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

class MobxChatBridge with PluginSessionListener {
MobxChatBridge(this._session) {
attachSubscriptions();
}
final PluginSession _session;
@override
PluginSession get session => _session;
@override
List<EventBinding> get subscriptions => [
on<ChatMessagesChanged>(_onMessagesChanged),
];
bool _disposed = false;
final Observable<List<ChatMessage>> messages = Observable<List<ChatMessage>>(
const <ChatMessage>[],
);
Future<void> send(String text) =>
_session.emit(SendMessageRequested(text)).then((_) {});
void _onMessagesChanged(EventEnvelope<ChatMessagesChanged> envelope) {
if (_disposed) return;
runInAction(() => messages.value = envelope.event.messages);
}
void dispose() {
_disposed = true;
detachSubscriptions();
}
}

Render with Observer(builder: (context) => ChatView(messages: bridge.messages.value, ...)). Source: mobx_chat.dart.

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.

/// Screen that reads its session from an injected locator-style holder.
///
/// In GetIt usage, swap [locatorGet] for [GetIt.I.get<PluginSession>()].
/// This snippet avoids the GetIt import so it compiles without the package.
class GetItChatScreen extends StatefulWidget {
/// Returns the active session from the service locator.
final PluginSession Function() locatorGet;
/// Creates a [GetItChatScreen] backed by [locatorGet].
const GetItChatScreen({super.key, required this.locatorGet});
@override
State<GetItChatScreen> createState() => _GetItChatScreenState();
}
class _GetItChatScreenState extends State<GetItChatScreen> {
late final PluginSession _session;
EventSubscription? _subscription;
List<ChatMessage> _messages = const <ChatMessage>[];
@override
void initState() {
super.initState();
_session = widget.locatorGet();
_subscription = _session.on<ChatMessagesChanged>((envelope) {
if (!mounted) return;
setState(() => _messages = envelope.event.messages);
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => ChatView(
title: 'GetIt',
messages: _messages,
onSend: (text) => _session.emit(SendMessageRequested(text: text)),
);
}

Source: get_it_chat_screen.dart.

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.

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.