Launch OpenGPEX

Signals & Cross-Plugin Communication

Signals are the reactive state primitives of the plugin system. They enable plugins to maintain internal state and communicate with other plugins in a structured, auditable way.


What Are Signals?

Signals are named reactive values stored in the global interaction state, scoped to a plugin via UID namespacing. They are:

  • Reactive — UI automatically re-renders when signal values change
  • Scoped — Auto-prefixed with plugin UID to prevent collisions
  • Typed — Support boolean, string, number, and complex values

Declaring Signals

Signals are declared in index.tsx as part of the plugin definition:

// index.tsx
export const plugin: EditorPlugin = {
  // ...
  signals: [
    {
      id: 'signal.active_tool',         // Local ID (will be auto-prefixed)
      name: 'Active Tool',              // Human-readable label
      defaultValue: 'brush',            // Initial value
      scope: 'public',                  // 'public' = other plugins may read this
    },
    {
      id: 'signal.panel_open',
      name: 'Panel Open State',
      defaultValue: false,
      scope: 'private',                 // 'private' = internal use only
    },
  ],
};

Signal Scope

Scope Meaning External Access
public Intentionally shared for cross-plugin consumption ✅ Via exported constants
private Internal UI state only ❌ By convention (not enforced at runtime)

Reading & Writing Signals (Within Plugin)

Use usePluginSignals() in your plugin's components:

import { usePluginSignals } from '@opengpex/editor/core/context';

function MyPanel() {
  const { activeToolSignal, panelOpenSignal } = usePluginSignals();

  return (
    <div>
      <p>Current: {activeToolSignal.value}</p>
      <button onClick={() => activeToolSignal.set('eraser')}>
        Switch to Eraser
      </button>
      <button onClick={() => panelOpenSignal.set(!panelOpenSignal.value)}>
        Toggle Panel
      </button>
    </div>
  );
}

In Commands (ctx.scoped)

execute: (ctx) => {
  // Read (short ID — auto-prefixes plugin UID)
  const tool = ctx.scoped!.getSignal('signal.active_tool', 'brush');

  // Write
  ctx.scoped!.setSignal('signal.active_tool', 'eraser');

  // Toggle boolean
  ctx.scoped!.toggleSignal('signal.panel_open');
}

Cross-Plugin Signal Reading

To read another plugin's public signal, export a constant from protocols.ts:

// In source plugin's protocols.ts
export const PLUGIN_ID = 'drawers.craft';
export const PLUGIN_AUTHOR = 'opengpex';
export const SIGNAL_ACTIVE_CRAFT = 'signal.active_craft';

// Cross-plugin UID constant (for external consumers)
export const CRAFT_DRAWER_SIGNAL_ACTIVE_CRAFT =
  `${PLUGIN_AUTHOR}.${PLUGIN_ID}.${SIGNAL_ACTIVE_CRAFT}`;

Consumer plugin reads it via useEditorState().getSignal() or state.getStateSignal():

import { CRAFT_DRAWER_SIGNAL_ACTIVE_CRAFT } from '../../drawers/CraftDrawer/protocols';

function MyComponent() {
  const { state } = useEditorState();
  const activeCraft = state.getStateSignal(CRAFT_DRAWER_SIGNAL_ACTIVE_CRAFT, null);
  // ...
}

Cross-Plugin Command Invocation

To call another plugin's command, use the full UID:

// In target plugin's protocols.ts — export cross-plugin constants
export const ADJUSTMENT_CMD_RESET_ALL =
  `${PLUGIN_AUTHOR}.${PLUGIN_ID}.${CMD_RESET_ALL_GRADING}`;
// In calling plugin
import { ADJUSTMENT_CMD_RESET_ALL } from '../../drawers/AdjustmentDrawer/protocols';

const { actions } = useEditorServices();
actions.executeCommand(ADJUSTMENT_CMD_RESET_ALL);

Typed API Facade Pattern

For plugins with many cross-plugin touchpoints, export a structured API object:

// protocols.ts — Structured facade for external plugins
export const AdjustmentDrawerAPI = {
  signals: {
    activeTool: `${PLUGIN_AUTHOR}.${PLUGIN_ID}.${SIGNAL_ACTIVE_GRADING_TOOL}`,
  },
  commands: {
    setTool: { uid: `${PLUGIN_AUTHOR}.${PLUGIN_ID}.${CMD_SET_GRADING_TOOL}` },
    resetAll: { uid: `${PLUGIN_AUTHOR}.${PLUGIN_ID}.${CMD_RESET_ALL_GRADING}` },
  },
  configKey: `${PLUGIN_AUTHOR}.${PLUGIN_ID}`,
} as const;

Best Practices

  1. Export cross-plugin constants only when needed — Don't create dead-code constants for commands/signals that have no external consumers. Add them when the need arises.
  2. Prefer Command Bus over direct signal writes — Reading other plugins' signals is fine; writing to them directly should be avoided (use commands to request state changes).
  3. Use private scope for UI-only state — Panel open/close, active tab, scroll position, etc.
  4. Use public scope for semantic state — Active tool, editing mode, generation status, etc.

Next Steps


Last updated: 2026-07-30