Web Loom logo
Plugin BookFramework-Agnostic Design Principles
Part I: Foundations and Theory

Framework-Agnostic Design Principles

Design plugins that work across frameworks and environments.

Chapter 3: Framework-Agnostic Design Principles and Patterns

In chapter 2 we left UIFramework<unknown> as the seam where framework adapters connect. That was the right call structurally, but it deferred a real question: if component types are abstract, how does a React plugin actually get mounted? How does a Vue plugin subscribe to SDK events in a way that integrates naturally with Vue's reactivity system? How does state shared between two plugins stay consistent when one was built in Angular and the other in vanilla JavaScript?

Keeping types abstract is the beginning of framework-agnostic design, not the end of it. Frameworks differ in far more than component signatures — they have different rendering models, different lifecycle timing, different event and reactivity systems. A plugin architecture that only abstracts types will still break when it tries to mount a Svelte component using React's rendering logic.

The real goal is to push all framework-specific behaviour into a dedicated layer — an adapter — so that the contracts at the core remain stable regardless of what is happening at the edges. This chapter is about how to design that adapter layer, what it must normalise, and how production systems have approached the same problem.


3.1 The Abstraction Layer Challenge

A framework-agnostic plugin system sits between two worlds: the host application, which is usually built in one primary framework, and plugin code, which could be built in anything or compiled to vanilla JavaScript. The abstraction layer is the bridge between them, and how far it extends depends on what the system is trying to protect.

How Production Systems Handle This

The approaches found in production systems form a spectrum, and understanding where each system sits on that spectrum is useful before designing your own.

Babel and Vite avoid the problem entirely. Their plugins operate on abstract syntax trees or module graphs at build time — there is no UI, no component model, and no framework lifecycle to harmonise. If your plugin system does not involve UI rendering at runtime, data structure abstraction is all you need, and the problem of framework differences simply does not arise.

VS Code takes the opposite approach: extensions run in separate Node.js processes and communicate with the main UI through a message-passing API. Extensions never directly manipulate the editor's UI; they declare contribution points and the host renders them. This makes extensions genuinely framework-neutral — they do not need to know or care what framework VS Code's UI is built in, because they never touch it.

// Extensions provide declarative UI contributions — no framework coupling
{
  "contributes": {
    "commands": [{
      "command": "extension.hello",
      "title": "Hello World",
      "icon": "$(heart)"
    }],
    "menus": {
      "editor/context": [{
        "command": "extension.hello",
        "when": "editorTextFocus"
      }]
    }
  }
}
 
// Extensions don't directly manipulate UI
export function activate(context: vscode.ExtensionContext) {
  const disposable = vscode.commands.registerCommand('extension.hello', () => {
    vscode.window.showInformationMessage('Hello World!');
  });
  context.subscriptions.push(disposable);
}

Beekeeper Studio takes a similar isolation-first approach but uses iframes rather than separate processes. Each plugin runs in an iframe and communicates with the host over postMessage. The plugin can use any frontend framework it likes — the host never sees the plugin's framework, only its messages:

class PluginIframe {
  private iframe: HTMLIFrameElement;
 
  constructor(private manifest: PluginManifest) {
    this.iframe = document.createElement('iframe');
    this.iframe.src = `plugin://${manifest.id}/index.html`;
    this.iframe.sandbox = 'allow-scripts allow-same-origin';
    this.setupCommunication();
  }
 
  private setupCommunication(): void {
    window.addEventListener('message', (event) => {
      if (event.source === this.iframe.contentWindow) {
        this.handlePluginMessage(event.data);
      }
    });
  }
 
  private handlePluginMessage(message: PluginMessage): void {
    switch (message.type) {
      case 'getTables':
        return this.sendToPlugin('tablesResponse', this.databaseService.getTables());
      case 'runQuery':
        return this.executeQuery(message.payload);
      case 'showNotification':
        return this.notificationService.show(message.payload);
    }
  }
}
 
// Inside the plugin iframe — any framework works here
function MyPlugin() {
  const [tables, setTables] = useState([]);
 
  useEffect(() => {
    window.parent.postMessage({ type: 'getTables' }, '*');
 
    const handleMessage = (event: MessageEvent) => {
      if (event.data.type === 'tablesResponse') {
        setTables(event.data.payload);
      }
    };
 
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, []);
 
  return (
    <div>
      {tables.map(table => <TableRow key={table.name} table={table} />)}
    </div>
  );
}

Backstage sits in the middle of the spectrum. It maintains separate but parallel plugin architectures for the frontend (React) and backend (Node.js). Rather than forcing a single abstraction across both environments, it embraces the difference while keeping lifecycle patterns consistent across both. Kibana follows a similar philosophy — it is React-first for UI plugins but provides framework-agnostic extension points (saved object types, search strategies, index patterns) for the parts of the system that do not involve rendering.

The lesson across all of these: the right level of abstraction depends on how much isolation you need. Process isolation and iframe isolation buy the most freedom for plugin authors but at the cost of communication overhead. In-process adapters are lighter but require more careful design of the component lifecycle boundary.

Strategies for Abstracting Components

When plugins do contribute UI and you need to support multiple frameworks, there are four practical strategies for handling component types at the boundary.

Type erasure is the simplest: accept any component type as unknown and let adapters handle the rest. The SDK remains completely neutral:

type RenderableComponent = unknown; // Framework-specific at runtime
 
interface PluginWidgetDefinition<TComponent = RenderableComponent> {
  id: string;
  title: string;
  render: TComponent;
  props?: Record<string, unknown>;
}

Virtual modules, a pattern from Vite and Rollup, let plugins export synthetic modules that the host imports at build time:

export const plugin = {
  resolveId(id: string) {
    if (id === 'virtual:my-plugin-ui') return id;
  },
  load(id: string) {
    if (id === 'virtual:my-plugin-ui') {
      return 'export const MyComponent = ...';
    }
  },
};
 
// Host imports the virtual module
import { MyComponent } from 'virtual:my-plugin-ui';

Declarative UI schemas define components as serialisable JSON objects that adapters then render in whichever framework the host uses. NocoBase uses this approach extensively — plugins describe their UI as a schema, and a renderer interprets it for the current framework:

interface DeclarativeWidget {
  type: 'container' | 'text' | 'button' | 'input';
  props?: Record<string, unknown>;
  children?: DeclarativeWidget[];
  events?: {
    [eventName: string]: string;
  };
}
 
// Schema renderer interprets in any framework
class SchemaRenderer {
  constructor(private framework: 'react' | 'vue' | 'angular') {}
 
  render(schema: UISchema): unknown {
    switch (this.framework) {
      case 'react':
        return this.renderReact(schema);
      case 'vue':
        return this.renderVue(schema);
      case 'angular':
        return this.renderAngular(schema);
    }
  }
 
  private renderReact(schema: UISchema): React.ReactElement {
    const Component = this.getReactComponent(schema['x-component']);
    return React.createElement(
      Component,
      schema['x-component-props'],
      this.renderChildren(schema.properties)
    );
  }
}

The schema approach gives maximum flexibility — the same plugin works across frameworks without recompiling — but requires a substantial investment in the schema renderer, and complex UI is hard to express declaratively.

Web components are the fourth option, and the most standards-aligned. Custom elements work in every modern framework as first-class citizens, and they carry their own lifecycle:

class PluginWidget extends HTMLElement {
  connectedCallback() {
    this.innerHTML = '<div>Plugin UI</div>';
  }
}
customElements.define('plugin-widget', PluginWidget);
 
// Used identically in any host
// <plugin-widget></plugin-widget>

The practical constraint is that web components have real friction when integrating with React (form participation, event handling, type definitions) and the developer experience for plugin authors is more demanding than framework-native APIs.

Normalising Events

Every framework handles events differently, and a cross-framework plugin system needs a common event model. The two dominant approaches in production systems are a centralised event bus and hook-based event declaration.

An event bus is the more universal solution. It decouples plugins from each other and from the host, and works identically regardless of what framework any given subscriber was written in:

// Plugin publishes events
sdk.events.emit('user:login', { userId: '123', timestamp: Date.now() });
 
// Other plugins subscribe
const unsubscribe = sdk.events.on('user:login', (payload) => {
  console.log('User logged in', payload.userId);
});
 
// Always return and call unsubscribe during plugin unmount
unsubscribe();

Hook-based events — as Babel and Vite use — are better suited to pipeline-style systems where the lifecycle of an operation has well-defined phases and plugins want to participate in specific phases:

export const plugin = {
  watchChange(id: string, event: { event: 'create' | 'update' | 'delete' }) {
    console.log(`File ${id} was ${event.event}d`);
  },
  hotUpdate({ file, timestamp }) {
    // Custom HMR logic
  },
};

Backstage provides a typed, RxJS-based variant of the event bus for backend plugins, where type safety across event names is important in a system where many teams publish and subscribe:

eventBus.ofType(OrderPlacedEvent).subscribe((event) => {
  sendConfirmationEmail(event.order);
});
 
eventBus.publish(new OrderPlacedEvent({ order }));

The important practice in all three approaches: subscriptions must be cleaned up. Always return an unsubscribe function from on() and call it during plugin unmount, or you will accumulate dead listeners across the plugin lifecycle.

Normalising Lifecycle

Framework lifecycle hooks map onto plugin lifecycle concepts, but the mapping is not one-to-one and the timing differs subtly between frameworks. A unified plugin lifecycle needs to abstract over these differences.

A simple lifecycle covers most UI plugin needs:

export interface PluginModule {
  /** One-time initialisation */
  init?: (sdk: PluginSDK) => void | Promise<void>;
 
  /** When UI should appear */
  mount?: (sdk: PluginSDK) => void | Promise<void>;
 
  /** When UI is removed */
  unmount?: () => void | Promise<void>;
}

For more complex plugins — those that need to configure the host before other plugins load, or that expose services for other plugins to depend on — a multi-phase lifecycle inspired by Kibana and Backstage gives finer control:

export interface PluginModule {
  /** Configuration phase — modify host config before plugins load */
  configure?: (config: HostConfig) => HostConfig;
 
  /** Setup phase — register capabilities and services */
  setup?: (context: SetupContext) => SetupContract;
 
  /** Start phase — activate services after all plugins have set up */
  start?: (context: StartContext) => StartContract;
 
  /** Shutdown phase — cleanup */
  stop?: () => Promise<void>;
}

The framework-specific translation of each lifecycle hook looks like this:

| Plugin Hook | React | Vue | Angular | Svelte | | --- | --- | --- | --- | --- | | init | Before first render | beforeCreate | constructor | onMount (before) | | mount | useEffect(() => {}, []) | mounted | ngOnInit | onMount | | unmount | useEffect cleanup | beforeUnmount | ngOnDestroy | onDestroy |

The host's adapter is responsible for translating plugin lifecycle calls into the appropriate framework-specific hooks. Plugin authors never call framework hooks directly.


3.2 The Adapter Pattern

The adapter pattern is the central structural idea of framework-agnostic plugin architecture. One PluginSDK interface, implemented differently behind the scenes depending on the host framework. Plugin code calls the same API; the adapter handles what that API actually does in the current environment.

The Adapter Interface

The full adapter interface defines everything a framework must provide to host plugins:

interface FrameworkAdapter<TComponent = unknown> {
  mountComponent: (
    component: TComponent,
    container: HTMLElement,
    props?: Record<string, unknown>
  ) => ComponentInstance;
 
  unmountComponent: (instance: ComponentInstance) => void;
 
  updateComponent: (
    instance: ComponentInstance,
    props: Record<string, unknown>
  ) => void;
 
  addEventListener: (
    instance: ComponentInstance,
    event: string,
    handler: (payload: unknown) => void
  ) => () => void;
 
  notifyStateChange: (key: string, value: unknown) => void;
 
  createReactiveBinding?: <T>(getter: () => T) => ReactiveValue<T>;
}
 
interface ComponentInstance {
  instance: unknown;
  container: HTMLElement;
  destroy: () => void;
}

The optional createReactiveBinding is the one hook that frameworks differ on most significantly — React does not have a native reactive primitive equivalent to Vue's ref, so this is genuinely optional.

Extreme Cases: VS Code and Beekeeper Studio

VS Code's adapter is the most elaborate because it bridges two operating system processes. API calls from the extension host must be serialised, sent over an IPC channel, executed in the main process, and the results returned. The adapter hides all of this from extension authors:

// Main process side
class MainThreadAdapter {
  async showMessage(message: string) {
    return this.rpc.call('showMessage', [message]);
  }
}
 
// Extension host side (different process)
class ExtensionHostAdapter {
  onShowMessage(handler: (msg: string) => void) {
    this.rpc.on('showMessage', handler);
  }
}

From the extension author's perspective, they call vscode.window.showInformationMessage() and a dialog appears. The process boundary is invisible.

Vite's adapter is at the opposite end of the complexity spectrum. Because Vite extends Rollup's plugin interface rather than replacing it, the adapter is essentially just the additional Vite-specific hooks that Rollup plugins do not have:

interface VitePlugin extends RollupPlugin {
  config?: (config: UserConfig) => UserConfig;
  configureServer?: (server: ViteDevServer) => void;
  transformIndexHtml?: (html: string) => string;
 
  resolveId?: (id: string) => string | null;
  load?: (id: string) => string | null;
  transform?: (code: string, id: string) => string | null;
}

Rollup plugins work in Vite without modification. Vite plugins can use Rollup hooks and gain access to additional ones. The extension is purely additive.

Framework Adapters

Here are complete implementations of the FrameworkAdapter interface for the four major frontend frameworks and Web Components.

React:

const ReactAdapter: FrameworkAdapter<React.ComponentType> = {
  mountComponent(component, container, props = {}) {
    const root = ReactDOM.createRoot(container);
    const element = React.createElement(component, props);
    root.render(element);
 
    return {
      instance: root,
      container,
      destroy() {
        root.unmount();
      },
    };
  },
 
  unmountComponent(instance) {
    instance.destroy();
  },
 
  updateComponent(instance, props) {
    const root = instance.instance as ReactDOM.Root;
    root.render(React.createElement(/* stored component */, props));
  },
 
  addEventListener(instance, event, handler) {
    const eventProp = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`;
    // React handles events through props — re-render with updated handler
    return () => {
      // Cleanup on unsubscribe
    };
  },
 
  notifyStateChange(key, value) {
    // Trigger React context update or re-render
  },
};

Vue 3:

const Vue3Adapter: FrameworkAdapter<Vue.Component> = {
  mountComponent(component, container, props = {}) {
    const app = Vue.createApp(component, props);
    const instance = app.mount(container);
 
    return {
      instance: app,
      container,
      destroy() {
        app.unmount();
      },
    };
  },
 
  unmountComponent(instance) {
    instance.destroy();
  },
 
  updateComponent(instance, props) {
    const app = instance.instance as Vue.App;
    Object.assign(app._instance?.props || {}, props);
  },
 
  addEventListener(instance, event, handler) {
    const app = instance.instance as Vue.App;
    app._instance?.emit(event, handler);
    return () => {
      // Vue 3 cleanup
    };
  },
 
  createReactiveBinding<T>(getter: () => T) {
    return Vue.computed(getter);
  },
};

Angular:

const AngularAdapter: FrameworkAdapter<Type<any>> = {
  mountComponent(component, container, props = {}) {
    @NgModule({
      declarations: [component],
      imports: [CommonModule],
    })
    class DynamicModule {}
 
    const moduleRef = createNgModuleRef(DynamicModule, injector);
    const componentRef = moduleRef.instance.createComponent(component);
 
    container.appendChild(componentRef.location.nativeElement);
 
    return {
      instance: componentRef,
      container,
      destroy() {
        componentRef.destroy();
        moduleRef.destroy();
      },
    };
  },
 
  unmountComponent(instance) {
    instance.destroy();
  },
 
  updateComponent(instance, props) {
    const componentRef = instance.instance as ComponentRef<any>;
    Object.assign(componentRef.instance, props);
    componentRef.changeDetectorRef.detectChanges();
  },
 
  addEventListener(instance, event, handler) {
    const componentRef = instance.instance as ComponentRef<any>;
    const subscription = componentRef.instance[event]?.subscribe(handler);
    return () => subscription?.unsubscribe();
  },
};

Svelte:

const SvelteAdapter: FrameworkAdapter<typeof SvelteComponent> = {
  mountComponent(component, container, props = {}) {
    const instance = new component({
      target: container,
      props,
    });
 
    return {
      instance,
      container,
      destroy() {
        instance.$destroy();
      },
    };
  },
 
  unmountComponent(instance) {
    instance.destroy();
  },
 
  updateComponent(instance, props) {
    const svelteInstance = instance.instance as SvelteComponent;
    svelteInstance.$set(props);
  },
 
  addEventListener(instance, event, handler) {
    const svelteInstance = instance.instance as SvelteComponent;
    svelteInstance.$on(event, (e: CustomEvent) => handler(e.detail));
    return () => {
      // Svelte doesn't provide $off — track listeners manually
    };
  },
};

Web Components:

const WebComponentAdapter: FrameworkAdapter<string> = {
  mountComponent(tagName, container, props = {}) {
    const element = document.createElement(tagName);
 
    Object.entries(props).forEach(([key, value]) => {
      (element as any)[key] = value;
    });
 
    container.appendChild(element);
 
    return {
      instance: element,
      container,
      destroy() {
        element.remove();
      },
    };
  },
 
  unmountComponent(instance) {
    instance.destroy();
  },
 
  updateComponent(instance, props) {
    const element = instance.instance as HTMLElement;
    Object.entries(props).forEach(([key, value]) => {
      (element as any)[key] = value;
    });
  },
 
  addEventListener(instance, event, handler) {
    const element = instance.instance as HTMLElement;
    element.addEventListener(event, handler as EventListener);
    return () => element.removeEventListener(event, handler as EventListener);
  },
};

Web Components are the only adapter that does not need a framework-specific teardown — element.remove() is sufficient. This makes them attractive as a universal fallback, though they carry their own limitations in complex form scenarios.

Selecting an Adapter

There are three broad strategies for determining which adapter to use at runtime.

The simplest is build-time selection: you know which framework the host uses, so you import that adapter at compile time. This is what Kibana and Backstage do — they commit to React for the frontend and never need to detect it at runtime. There is no adapter selection logic; there is simply one adapter.

Multi-framework hosts that want to support plugins from different ecosystems can detect the adapter at runtime:

function detectFramework(): FrameworkAdapter {
  if (typeof React !== 'undefined') return ReactAdapter;
  if (typeof Vue !== 'undefined') return Vue3Adapter;
  return WebComponentAdapter;
}

This works well enough for the common case, but detection based on global variables is fragile — a page can have both React and Vue loaded simultaneously, and the first match wins. A more reliable approach is to require plugin manifests to declare their framework and look up the appropriate adapter from a registry.


3.3 Plugin Component Architecture

The adapter defines how the host mounts components. The component architecture defines how plugins contribute them. These are separate concerns, and getting both right matters.

Declarative vs Direct Rendering

The most important architectural decision is whether plugins contribute components directly or declare what they want to contribute and let the host decide how to render it.

VS Code takes the declarative approach. Extensions declare contribution points in JSON — views, commands, menus, panels — and the editor renders them according to its own layout logic. An extension never touches the DOM or the editor's component tree:

{
  "contributes": {
    "views": {
      "explorer": [
        {
          "id": "myView",
          "name": "My Custom View"
        }
      ]
    },
    "commands": [
      {
        "command": "myExt.doSomething",
        "title": "Do Something"
      }
    ]
  }
}

The extension provides the data model (a TreeDataProvider implementation), but VS Code owns the rendering. This is what makes extensions resilient to VS Code's own UI updates — the extension never assumed anything about how the tree would be rendered.

Backstage takes the direct rendering approach for frontend plugins: React components are first-class, and plugins contribute them explicitly:

export const MyPlugin = createPlugin({
  routes: {
    root: rootRouteRef,
  },
});
 
export const MyPage = MyPlugin.provide(
  createRoutableExtension({
    name: 'MyPage',
    component: () => import('./components/MyPage').then((m) => m.MyPage),
    mountPoint: rootRouteRef,
  }),
);

The right choice depends on how much control you want to give plugin authors over their own UI. Declarative contribution is safer and more consistent — the host controls the chrome. Direct rendering gives plugin authors more freedom but requires more discipline in keeping plugins from interfering with each other or with the host layout.

Framework-Neutral Component Representation

At the SDK level, regardless of which approach you choose, components are best represented in a framework-neutral way. The adapter fills in the framework-specific details when the component is actually rendered:

interface PluginRouteDefinition<TComponent = unknown> {
  path: string;
  component: TComponent;
  props?: Record<string, unknown>;
  meta?: {
    title?: string;
    icon?: string;
    requiresAuth?: boolean;
  };
}

For scenarios where the right component depends on runtime context, a factory function defers the decision to the moment of activation:

interface PluginRouteDefinition {
  path: string;
  componentFactory: (sdk: PluginSDK) => unknown;
}
 
sdk.routes.add({
  path: '/dashboard',
  componentFactory: (sdk) => {
    return sdk.system.platform.isBrowser ? BrowserDashboard : ServerDashboard;
  },
});

Slot-Based Composition

Named slots give the host layout predictable places for plugins to contribute UI without allowing arbitrary insertion anywhere:

interface PluginSlot {
  slot: 'sidebar' | 'header' | 'footer' | 'main';
  component: unknown;
  order?: number;
}
 
sdk.ui.addSlot({
  slot: 'sidebar',
  component: MySidebarWidget,
  order: 10,
});
 
function HostLayout() {
  const sidebarComponents = sdk.ui.getSlotsFor('sidebar');
  return (
    <div>
      <Sidebar>
        {sidebarComponents.map(c => renderComponent(c))}
      </Sidebar>
    </div>
  );
}

The order field gives the host control over how multiple plugins claiming the same slot are stacked. Without ordering, the last-registered plugin would always win, which makes behaviour unpredictable as the ecosystem grows.

Backstage extends this idea to plugin-to-plugin composition, where one plugin can define extension points that other plugins extend:

// A plugin defines an extension point
export const myCatalogExtensionPoint = createExtensionPoint<CatalogExtension>({
  id: 'catalog.extensions',
});
 
// Another plugin extends it
env.registerInit({
  deps: { catalog: myCatalogExtensionPoint },
  async init({ catalog }) {
    catalog.addProcessor(new MyCustomProcessor());
  },
});

This pattern allows a rich plugin ecosystem where plugins are not just extending the host — they can extend each other, creating a composable hierarchy of capabilities.

Props and Data Flow

The cleanest way to give plugin components access to SDK capabilities is to inject the SDK as a prop. The adapter handles this automatically so plugin authors do not need to import anything from the SDK directly:

function adaptPluginComponent(component: unknown, sdk: PluginSDK) {
  return ReactAdapter.mountComponent(component, container, {
    sdk,
    ...additionalProps,
  });
}
 
function MyPluginComponent({ sdk }: { sdk: PluginSDK }) {
  const handleClick = () => sdk.events.emit('buttonClicked');
  return <button onClick={handleClick}>Click me</button>;
}

For React-specific hosts, context providers are often cleaner — they avoid prop-drilling through component trees and let any component in the plugin's tree access the SDK without explicit wiring:

function PluginComponentWrapper({ component: Component, sdk }: Props) {
  return (
    <PluginSDKContext.Provider value={sdk}>
      <Component />
    </PluginSDKContext.Provider>
  );
}
 
function MyPluginComponent() {
  const sdk = usePluginSDK();
  // Use SDK through context
}

The context approach requires a React-specific wrapper but produces cleaner plugin component code. For truly multi-framework hosts, prop injection is more universally applicable.


3.4 Cross-Framework State and Reactivity

State in a plugin system has two layers: the state that belongs to a single plugin, and the state that needs to be shared across plugins. Getting this right matters both for correctness (plugins should not accidentally read each other's private data) and for performance (reactive state updates should only trigger the components that care about them).

Plugin State Isolation

The default should always be isolation. VS Code handles this by automatically namespacing each extension's storage:

const state = context.workspaceState;
await state.update('myData', { count: 1 });
// No collision with other plugins using 'myData' — the namespace is the extension ID

Build-tool plugins like Babel and Vite scope state to the execution context of a single transformation pass, which provides natural isolation for free:

export const plugin = {
  pre(state) {
    this.identifiers = new Set();
  },
  visitor: {
    Identifier(path) {
      this.identifiers.add(path.node.name);
    },
  },
  post() {
    console.log(Array.from(this.identifiers));
  },
};

Sharing State Across Plugins

When state genuinely needs to be shared — a theme setting, the currently authenticated user, the active locale — there are three practical patterns.

A namespaced global store is the most explicit: plugins read and write to named keys, subscribe to changes, and the SDK ensures key namespacing so plugins do not collide:

sdk.state.global.set('theme', 'dark');
sdk.state.global.set('locale', 'en-US');
 
const unsubscribe = sdk.state.global.subscribe('theme', (theme) => {
  console.log('Theme changed:', theme);
});

Event-based state synchronisation avoids a central store entirely — one plugin updates state and broadcasts the change; others react:

sdk.events.emit('state:theme:changed', { theme: 'dark' });
 
sdk.events.on('state:theme:changed', ({ theme }) => {
  updateUITheme(theme);
});

Shared services — the pattern used by Vendure and Backstage — are the most structured option. State lives in an injected service that plugins receive through dependency injection, making the dependency explicit and testable:

class ThemeService {
  private currentTheme = 'light';
 
  setTheme(theme: string) {
    this.currentTheme = theme;
    this.eventBus.emit(new ThemeChangedEvent(theme));
  }
 
  getTheme(): string {
    return this.currentTheme;
  }
}
 
setup(deps: { theme: ThemeService }) {
  deps.theme.setTheme('dark');
}

Reactive Bindings

Each framework has its own reactivity primitive, and plugins benefit from bindings that feel native to their framework rather than a foreign subscribe() call.

For React, a custom hook that wraps the SDK's subscribe method:

function usePluginState<T>(key: string): [T, (value: T) => void] {
  const [state, setState] = useState<T>(() => sdk.state.get(key));
 
  useEffect(() => {
    return sdk.state.subscribe(key, setState);
  }, [key]);
 
  const updateState = useCallback((value: T) => {
    sdk.state.set(key, value);
  }, [key]);
 
  return [state, updateState];
}
 
function MyComponent() {
  const [theme, setTheme] = usePluginState<string>('theme');
  return <button onClick={() => setTheme('dark')}>Dark Mode</button>;
}

For Vue, binding to the SDK's state through ref:

function usePluginState<T>(key: string) {
  const state = ref<T>(sdk.state.get(key));
 
  sdk.state.subscribe(key, (value) => {
    state.value = value;
  });
 
  return {
    state,
    updateState: (value: T) => sdk.state.set(key, value),
  };
}

For a framework-agnostic implementation that any framework can adapt to, RxJS BehaviorSubject provides a reactive observable that carries the current value:

import { BehaviorSubject } from 'rxjs';
 
class ReactiveStateService {
  private subjects = new Map<string, BehaviorSubject<any>>();
 
  observe<T>(key: string): Observable<T> {
    if (!this.subjects.has(key)) {
      this.subjects.set(key, new BehaviorSubject(undefined));
    }
    return this.subjects.get(key)!.asObservable();
  }
 
  set<T>(key: string, value: T): void {
    if (!this.subjects.has(key)) {
      this.subjects.set(key, new BehaviorSubject(value));
    } else {
      this.subjects.get(key)!.next(value);
    }
  }
}
 
sdk.state.observe<string>('theme').subscribe((theme) => {
  console.log('Theme changed:', theme);
});

Performance

Two patterns have an outsized impact on performance in any framework-agnostic plugin system.

Batched updates prevent a cascade of re-renders when multiple state changes happen in the same tick. Rather than notifying subscribers on every set() call, collect pending updates and flush them together:

class StateService {
  private pendingUpdates = new Map<string, any>();
  private batchTimer: any;
 
  set<T>(key: string, value: T) {
    this.pendingUpdates.set(key, value);
 
    if (!this.batchTimer) {
      this.batchTimer = setTimeout(() => {
        this.flush();
      }, 0);
    }
  }
 
  private flush() {
    for (const [key, value] of this.pendingUpdates) {
      this.applyUpdate(key, value);
    }
    this.pendingUpdates.clear();
    this.batchTimer = null;
  }
}

Visitor caching, drawn from Babel, applies wherever plugins register handlers for the same events or data structures. Rather than re-normalising each plugin's handler map on every invocation, compute it once and cache it:

const visitorCache = new WeakMap<PluginObj, NormalizedVisitor>();
 
function getNormalizedVisitor(plugin: PluginObj): NormalizedVisitor {
  if (visitorCache.has(plugin)) {
    return visitorCache.get(plugin)!;
  }
 
  const normalized = normalizeVisitor(plugin.visitor);
  visitorCache.set(plugin, normalized);
  return normalized;
}

The broader performance lesson from the systems we have studied: lazy loading (loading a plugin only when it is needed) is more impactful than any micro-optimisation inside the plugin system itself. A plugin that has not been activated yet has zero cost. Chapter 14 covers lazy loading and bundle strategies in detail.


Conclusion

Framework-agnostic design is not a single technique — it is a series of decisions about where to draw lines. Where does the stable, framework-neutral contract end and the framework-specific adapter begin? Which things does the host own and which does the plugin own? What gets declared versus what gets rendered?

The adapter pattern is the central structural answer: keep one interface, implement it differently per framework, and hide all the differences from the plugin author. Babel and Vite achieve agnosticism by having no UI at all. VS Code achieves it through process isolation and declarative contributions. Beekeeper Studio achieves it through iframe sandboxing and message passing. Backstage embraces separate symmetric environments rather than forcing a single abstraction.

The right level of abstraction for your system depends on how much isolation you need between the host and plugins, and how much freedom you want to give plugin authors over their UI. More isolation means more overhead but also more safety. Direct rendering gives more flexibility but requires more trust in plugin authors. Most production systems settle somewhere in the middle and add isolation as the ecosystem grows and the threat model becomes clearer.

In the next chapter we will formalise the core contracts — PluginManifest, PluginModule, and PluginSDK — turning the principles from this chapter and the TypeScript patterns from chapter 2 into the complete, stable interface set that every plugin in our system will build against.

Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.