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.
The shape every recipe shares
Section titled “The shape every recipe shares”A state holder bridge has the same three duties no matter which library you pick.
- Subscribe to the relevant fact event on the session bus (
ChatMessagesChangedin these recipes), store the resultingEventSubscriptionin a field, and update the holder’s state inside the handler. - Translate UI intents into bus events with
session.emit(SomeIntent(...)). - 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
Section titled “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
Section titled “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:
CubitchecksisClosed.Statechecksmounted.ChangeNotifier, signals, MobX bridges flip a local_disposedflag indispose()before cancelling.
Without the guard, a late fire that calls notifyListeners or emit on a dead holder throws.
A shared chat protocol
Section titled “A shared chat protocol”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.
Recipe: StatefulWidget + setState
Section titled “Recipe: StatefulWidget + setState”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.
With flutter_plugin_kit
Section titled “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.
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.
Recipe: ChangeNotifier + provider
Section titled “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.
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.
With flutter_plugin_kit
Section titled “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.
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
Section titled “Recipe: flutter_bloc”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.
Recipe: Riverpod
Section titled “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.
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.
Recipe: signals_flutter
Section titled “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.
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.
Recipe: MobX
Section titled “Recipe: MobX”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.
Recipe: GetIt
Section titled “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.
/// 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.
Things every recipe avoids
Section titled “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’sbuild(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()andruntime.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 withEventEnvelope<T>. The payload is atenvelope.event.
Lifecycle hazards worth knowing
Section titled “Lifecycle hazards worth knowing”These show up rarely but bite hard.
- Settings reconciliation does not dispose the session.
updateSessionSettingsmutates 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 theon<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/updateSessionSettingswhile one is already in flight raises aStateErrorfrom_enterReconcilerather 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.