Flutter Integration
Plugin Kit’s core package is pure Dart, with no flutter SDK dependency and no widget imports, so the same runtime drops into a CLI, a server, or a Flutter app unchanged. This page is about wiring that runtime into a widget tree, either directly or through whatever state library you already use.
The reference integration in this repo is example/code_editor/ — try it at plugin-kit.saad-ardati.dev/code-editor — a full Flutter app built entirely on StatefulWidget and setState with no DI or state-management package. Everything below mirrors that pattern.
The mental model
Section titled “The mental model”Plugin Kit can sit alongside your state management or carry a fair amount of state-management work itself. The bus, registry, and session lifecycle overlap with what most state libraries do, so the choice is about how much overlap you want to pay for, not about which tool is allowed to do which job.
| Tool | Good at |
|---|---|
Provider / Riverpod / Bloc / GetIt | exposing values to widgets, scheduling rebuilds, mature widget integrations |
| Plugin Kit | lifecycle-aware feature composition, replaceable services, isolated sessions, runtime configuration, and (incidentally) a typed event bus that can drive setState directly |
If you keep both, the usual split is: state management exposes values to widgets, Plugin Kit decides which features exist and how they participate, and a small bridge connects the two. If you drop the state library, the session bus drives setState straight from the shell. The reference example takes that path.
For plugin-provided UI, that bridge often resolves widget factories from the registry. For app behavior, prefer event contracts: widgets and state holders emit typed events, plugins react, and the Flutter layer does not need to know which service did the work.
The nicest Flutter surfaces for Plugin Kit are the ones users can see:
- a model selector whose options follow enabled plugins
- a customization dialog that edits runtime settings live
- a chat timeline that grows richer blocks as plugins stream progress and results
Those are all the same integration story. The widget tree provides the shell. The runtime decides which features participate.
The reference pattern
Section titled “The reference pattern”The cleanest integration, used by the code_editor example, looks like this.
- A stateful shell widget holds a
PluginRuntimeand the currentPluginSessionas fields. - The shell subscribes to session-bus events it cares about and calls
setStatein those handlers. - Plugins contribute widgets to the shell by registering factories in the registry.
- Plugins contribute descriptors (what should appear in the toolbar, panel list, status bar) by mutating collection events the shell emits.
- When the user toggles a feature, the shell calls
updateSessionSettings(...)with serialization to prevent races. - The shell’s
dispose()disposes the runtime, which tears down sessions cleanly.
No extra package. Just Flutter, with the shell owning every dispose call. (For a version that hides the bookkeeping behind a scope widget and a mixin, see flutter_plugin_kit.)
A minimal editor shell
Section titled “A minimal editor shell”class EditorScreen extends StatefulWidget { /// Creates an [EditorScreen]. const EditorScreen({super.key});
@override State<EditorScreen> createState() => _EditorScreenState();}
class _EditorScreenState extends State<EditorScreen> { late final PluginRuntime _runtime; PluginSession? _session;
List<PanelDescriptor> _panels = [];
@override void initState() { super.initState(); _runtime = PluginRuntime(plugins: [TerminalPlugin(), MinimapPlugin()]); _runtime.init(); _createSession(); }
Future<void> _createSession() async { _session = await _runtime.createSession();
_session!.on<UIRefreshRequest>((_) async { if (!mounted) return; await _collectPanels(); });
await _collectPanels(); }
Future<void> _collectPanels() async { final collect = CollectPanels(); await _session!.emit(collect);
if (!mounted) return; setState(() => _panels = collect.panels); }
@override void dispose() { // runtime.dispose() iterates and disposes active sessions. Do NOT call // session.dispose() separately. Doing both races on stateful service // detach and can throw ConcurrentModificationError. _runtime.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return Scaffold( body: Row(children: [for (final panel in _panels) _resolvePanel(panel)]), ); }
Widget _resolvePanel(PanelDescriptor panel) { const ns = Namespace('panel'); final factory = _session?.context.maybeResolve<PanelWidgetFactory>( ns(panel.id), ); return factory?.build(context) ?? const SizedBox.shrink(); }}That is the whole bridge. Session events trigger setState. Plugins contribute via the registry and via events. The widget tree never imports the ServiceRegistry or subscribes directly to plugin internals.
Plugins contributing widgets
Section titled “Plugins contributing widgets”Widgets that plugins contribute live in the registry, behind an interface the shell knows about.
Define an interface in the shell:
abstract class PanelWidgetFactory { Widget build(BuildContext context);}Implement it in the plugin. The factory typically extends StatefulPluginService so it inherits session lifecycle and automatic subscription tracking.
class TerminalPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('terminal');
@override void register(ScopedServiceRegistry registry) { const panel = Namespace('panel'); registry.registerSingleton<PanelWidgetFactory>( panel('terminal'), // ServiceId('panel.terminal') () => TerminalPanelFactory(), ); }}The factory keeps its own state (_history). To trigger a rebuild, it emits UIRefreshRequest on the session bus. The shell hears it, calls setState, and the widget’s build(context) runs as part of that rebuild.
Because the factory is a StatefulPluginService, the runtime manages its lifecycle automatically. It attaches when the plugin enables, detaches when the plugin disables, and its tracked subscriptions are cancelled without manual cleanup.
Plugins contributing descriptors
Section titled “Plugins contributing descriptors”For things like toolbar items, status bar entries, or panel lists, the shell emits a “collection” event. Plugins listen for it and append to the mutable list inside.
class CollectPanels { final List<PanelDescriptor> panels = [];}Because every enabled plugin’s handler runs in priority order on the same mutable event object, one emit produces the final list in-place. No separate collect-then-fold step.
Toggling plugins at runtime
Section titled “Toggling plugins at runtime”When a user enables or disables a plugin mid-session, the shell calls updateSessionSettings(...) on the runtime.
Future<void> updateSessionPlugin( PluginRuntime runtime, PluginSession session,) async { final next = RuntimeSettings( plugins: { ...session.settings.plugins, const PluginId('experimental_feature'): const PluginConfig(enabled: true), }, ); await runtime.updateSessionSettings(session, newSettings: next);}Lifecycle gotchas
Section titled “Lifecycle gotchas”Dispose each session exactly once. PluginRuntime.dispose() iterates the active sessions it still owns and disposes them. If you plan to call runtime.dispose(), do not also call session.dispose() for those same sessions.
register and attach do not re-run on hot reload. Plugins are constructed once, and hot reload does not re-run main or re-execute initState. If you change code inside a plugin’s register or attach, use hot restart. Pure widget code inside factories still hot-reloads fine, because that code runs on rebuild.
Use mounted checks after async work. Session event handlers are async. If a handler awaits something and then calls setState, check mounted first. The widget may have been disposed in the meantime.
If you already use Riverpod, Provider, or Bloc
Section titled “If you already use Riverpod, Provider, or Bloc”The shape of the integration above is the same. Hold the runtime where your package expects long-lived services, and expose the current session (or services resolved from it) through whatever mechanism you already use.
For the library-specific bridge code (Provider/ChangeNotifier, Riverpod
AsyncNotifier, flutter_bloc Cubit, signals_flutter, MobX, GetIt) see
State Management Bridges. Every
recipe there is implemented in example/state_garden/ and run by
flutter test, so the citations are checked code rather than prose.
Testing Flutter widgets that use Plugin Kit
Section titled “Testing Flutter widgets that use Plugin Kit”Plugin Kit works with flutter_test like any other Dart dependency. The pattern:
/// Demonstrates the pattern for testing a Flutter widget backed by a runtime.Future<void> pumpEditorShell( PluginRuntime runtime, Future<void> Function(Widget) pump,) async { runtime.init(); await pump(const MaterialApp(home: EditorScreen()));}For widgets that resolve services on build, register stubs or fakes in a test-only plugin. The registry does not distinguish “real” from “fake” implementations. Higher priority wins, same as production.
class FakeSearchPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('fake_search');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<SearchService>( const ServiceId('search'), () => FakeSearch(), priority: Priority.system, // beat any other registrant ); }}See Testing for non-widget plugin tests.