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
- 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.
- 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).
- Use
privatescope for UI-only state — Panel open/close, active tab, scroll position, etc. - Use
publicscope for semantic state — Active tool, editing mode, generation status, etc.
Next Steps
- Plugin API Reference — Full hook documentation
- Your First Plugin — Tutorial with signal usage
- Plugin System Overview — Architecture context
Last updated: 2026-07-30