Lots of good suggestions here. This is how I would do it. Two slightly different approaches here. I would go with the singleton approach (see alternative at the bottom). After registering the custom project panel in the DesignerHook:
1. Gateway — ComponentModelDelegate
The ComponentModelDelegate is created per component instance. Load the config by asking the ProjectManager for the project's resource collection, then push it down to the client over the WebSocket on startup.
public class MyComponentModelDelegate extends ComponentModelDelegate {
private static final String EVENT_CONFIG_UPDATE = "config-update";
private final GatewayContext gatewayContext;
private final Gson gson = new Gson(); // or your module's configured Gson
public MyComponentModelDelegate(Component component, GatewayContext gatewayContext) {
super(component);
this.gatewayContext = gatewayContext;
}
@Override
protected void onStartup() {
getConfig().ifPresent(config -> {
JsonObject payload = gson.toJsonTree(config).getAsJsonObject();
fireEvent(EVENT_CONFIG_UPDATE, payload);
});
}
@Override
public void handleEvent(EventFiredMsg message) {
// handle any events fired by the client-side delegate if needed
}
private Optional<CustomComponentConfig> getConfig() {
String projectName = component.getSession().getProjectName();
return gatewayContext.getProjectManager()
.getProject(projectName)
.flatMap(project -> {
Resource resource = project.getResourceCollection()
.getResource(CustomComponentConfig.RESOURCE_TYPE);
if (resource == null) return Optional.empty();
String json = new String(resource.getData(), StandardCharsets.UTF_8);
return Optional.of(gson.fromJson(json, CustomComponentConfig.class));
});
}
}
Registering the delegate factory in your GatewayHook
@Override
public void startup(GatewayContext context, ...) {
PerspectiveContext perspectiveContext = PerspectiveContext.get(context);
perspectiveContext.getComponentRegistry()
.registerComponentModelDelegateFactory(
MyComponent.COMPONENT_ID,
comp -> new MyComponentModelDelegate(comp, context)
);
}
Reacting to project saves
If you need the delegate to re-push updated config when the project is saved without restarting the component, subscribe to the PerspectiveProjectCache event bus:
@Override
protected void onStartup() {
PerspectiveContext.get(gatewayContext)
.getProjectCache()
.getEventBus()
.register(this);
pushConfig();
}
@Override
protected void onShutdown() {
PerspectiveContext.get(gatewayContext)
.getProjectCache()
.getEventBus()
.unregister(this);
}
@Subscribe
public void onProjectUpdated(ProjectUpdatedEvent event) {
if (event.project.name.equals(component.getSession().getProjectName())) {
pushConfig();
}
}
private void pushConfig() {
getConfig().ifPresent(config -> {
JsonObject payload = gson.toJsonTree(config).getAsJsonObject();
fireEvent(EVENT_CONFIG_UPDATE, payload);
});
}
2. Client — ComponentStoreDelegate
Create a TypeScript class that extends ComponentStoreDelegate. Implement handleEvent to receive events from the gateway delegate, store the data locally, and call this.notify() to trigger a re-render. Expose the data to React via mapStateToProps.
import { ComponentStoreDelegate, AbstractUIElementStore, JsObject } from '@inductiveautomation/perspective-client';
import { bind } from 'bind-decorator';
interface CustomComponentConfig {
someValue: string;
someList: string[];
}
interface MyDelegateState {
config: CustomComponentConfig | null;
}
export class MyComponentStoreDelegate extends ComponentStoreDelegate {
private config: CustomComponentConfig | null = null;
constructor(component: AbstractUIElementStore) {
super(component);
}
@bind
handleEvent(eventName: string, eventObject: JsObject): void {
switch (eventName) {
case 'config-update':
this.config = eventObject as CustomComponentConfig;
this.notify(); // triggers mapStateToProps → component re-renders
break;
default:
console.warn(`Unhandled delegate event: ${eventName}`);
}
}
@bind
mapStateToProps(): MyDelegateState {
return { config: this.config };
}
}
Wiring into ComponentMeta
import { ComponentMeta, AbstractUIElementStore, ComponentStoreDelegate } from '@inductiveautomation/perspective-client';
export class MyComponentMeta implements ComponentMeta {
getComponentType(): string {
return 'your.module.id.my-component';
}
getViewComponent(): PComponent {
return MyComponent;
}
createDelegate(component: AbstractUIElementStore): ComponentStoreDelegate | undefined {
return new MyComponentStoreDelegate(component);
}
getPropsReducer(tree: PropertyTree): MyProps {
return {
// map PropertyTree to props as normal
};
}
}
Consuming delegate state in your React component
The ComponentStore merges your mapStateToProps() return value into props.delegate. Type it via the second generic parameter on ComponentProps.
import { ComponentProps, PComponent } from '@inductiveautomation/perspective-client';
type MyProps = {
// your normal component props from the property tree
};
export class MyComponent extends PComponent<ComponentProps<MyProps, MyDelegateState>> {
render() {
const { config } = this.props.delegate ?? {};
if (!config) {
return <div>Loading...</div>;
}
return (
<div>
<span>{config.someValue}</span>
</div>
);
}
}
Data Flow Diagram
Designer (Swing)
AbstractPropertyEditorPanel
initProps() ← deserialize() ← project resource bytes
setChanged() ← user edits
commit() → serialize() → project resource bytes
│
│ project save
▼
Gateway resource collection
│
│ component startup / project update
▼
ComponentModelDelegate.onStartup()
getConfig() reads resource bytes → CustomComponentConfig
fireEvent("config-update", payload)
│
│ WebSocket
▼
ComponentStoreDelegate.handleEvent("config-update", payload)
this.config = payload
this.notify()
│
▼
mapStateToProps() → { config }
│
▼
props.delegate.config → React render
Alternative: Singleton Component + Window Store
Instead of wiring each component with its own ComponentModelDelegate, you can designate a single singleton component that owns the gateway connection and publishes received config into a shared store mounted on window. Other components read from the store and subscribe to changes using the subscriptionFactory API.
This trades per-component WebSocket wiring for a single connection point with a shared observable. It's a good fit when many components need the same config and you don't want each one to independently negotiate a delegate lifecycle.
The window store
Create a store class that holds the config and exposes the subscription API. Mount it on window once so all components share the same instance.
// CustomComponentStore.ts
import {
createSubscriptionMap,
subscriptionFactory,
SubscriptionHandler,
} from '@inductiveautomation/perspective-client';
interface CustomComponentConfig {
someValue: string;
someList: string[];
}
const STATE_CONFIG = 'config';
class CustomComponentStore {
private config: CustomComponentConfig | null = null;
private subscriptionMap = createSubscriptionMap([STATE_CONFIG]);
public subscribe: SubscriptionHandler;
private notify: (state?: string) => void;
constructor() {
const { subscribe, notify } = subscriptionFactory(
this.subscriptionMap,
'CustomComponentStore'
);
this.subscribe = subscribe;
this.notify = notify;
}
setConfig(config: CustomComponentConfig): void {
this.config = config;
this.notify(STATE_CONFIG); // notify subscribers of the 'config' state key
}
getConfig(): CustomComponentConfig | null {
return this.config;
}
}
declare global {
interface Window {
customComponentStore: CustomComponentStore;
}
}
if (!window.customComponentStore) {
window.customComponentStore = new CustomComponentStore();
}
export const store = window.customComponentStore;
Singleton component delegate
The singleton component's ComponentStoreDelegate is the only place that receives events from the gateway and writes to the store. No other component needs a delegate for config.
import { ComponentStoreDelegate, AbstractUIElementStore, JsObject } from '@inductiveautomation/perspective-client';
import { store } from './CustomComponentStore';
export class SingletonComponentStoreDelegate extends ComponentStoreDelegate {
constructor(component: AbstractUIElementStore) {
super(component);
}
handleEvent(eventName: string, eventObject: JsObject): void {
switch (eventName) {
case 'config-update':
store.setConfig(eventObject as CustomComponentConfig);
break;
}
}
}
The gateway side (ComponentModelDelegate) is identical to the approach in section 1 — it pushes fireEvent("config-update", payload) on startup and on project save. The only difference is that only the singleton component type needs a delegate registered, not every component type.
Consuming components
Other components read from the store directly and subscribe to changes in their React lifecycle.
The disposer returned by subscribe must be called on unmount to avoid memory leaks.
import { store } from './CustomComponentStore';
export class OtherComponent extends PComponent<ComponentProps<OtherProps>> {
private unsubscribe?: () => void;
componentDidMount() {
// subscribe to the 'config' state key specifically
this.unsubscribe = store.subscribe(() => {
this.forceUpdate();
}, 'config');
}
componentWillUnmount() {
this.unsubscribe?.();
}
render() {
const config = store.getConfig();
if (!config) return <div>Loading...</div>;
return <div>{config.someValue}</div>;
}
}
To subscribe to any change on the store (not a specific state key), omit the second argument:
this.unsubscribe = store.subscribe(() => {
this.forceUpdate();
});
The subscriptionFactory will route this to the ANY_CHANGE_KEY listeners, which are notified on every notify() call regardless of which state key changed.
Data flow diagram
Gateway delegate (singleton component only)
fireEvent("config-update", payload)
│
│ WebSocket
▼
SingletonComponentStoreDelegate.handleEvent()
store.setConfig(payload)
→ this.notify('config')
│
├──→ OtherComponent A subscriber fires → forceUpdate()
├──→ OtherComponent B subscriber fires → forceUpdate()
└──→ OtherComponent C subscriber fires → forceUpdate()
↓
store.getConfig() → render