Part 15 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
Form-JS's event bus was designed for one purpose: internal form lifecycle communication. form.init fires when the form initializes. changed fires when field data changes. submit fires when the form is submitted. These events coordinate Form-JS's internal systems — renderers, validators, field registries — without any of them needing direct references to each other.
By the time I had built five evaluators, three validators, a React/Preact bridge, and a cross-framework file handling system, I had used the event bus for seven purposes it was never designed for. Custom lifecycle events. Cross-evaluator state broadcasting. Data passing from outside the form into evaluators. A cross-framework data store attached directly to the event bus instance.
Some of these uses are clean patterns worth intentionally adopting. Some are pragmatic hacks that solve real problems with real tradeoffs. This article documents all of them honestly.
What the Event Bus Was Designed For
The event bus is an instance of EventEmitter (from diagram-js's internal utilities) created per form instance. Its API is three methods:
// Subscribe to an event
eventBus.on('event.name', (event) => {
// handler receives the event payload
});
// Unsubscribe from an event
eventBus.off('event.name', handler);
// Fire an event with a payload
eventBus.fire('event.name', { key: 'value' });
Form-JS uses it internally to coordinate between services that don't hold direct references to each other. The field renderer doesn't import the validator directly — it fires changed and the validator listens. The form doesn't import the evaluators directly — they listen to form.init and changed.
This loose coupling is the design intent. Services declare what events they care about and what events they produce. They don't know who else is listening or who else is producing.
The same loose coupling that makes it useful internally makes it useful for extensions.
Use 1: Custom Lifecycle Events
Events fired: form.rendered
Where: CustomForm.importSchema()
Form-JS's form.init event fires when the form's DI container is initialized — before the schema is imported and before anything is rendered. There is no built-in event for "the form has finished rendering and is ready for user interaction."
I needed this event for two reasons: the DisabledEvaluator needs to apply disabled states to DOM elements after they exist, and external code consuming CustomForm needs to know when the form is safe to interact with.
// CustomForm.js
export class CustomForm extends Form {
private _isRendered: boolean = false;
async importSchema(schema: any, data?: any): Promise<any> {
try {
const result = await super.importSchema(schema, data);
// ✅ Mark as rendered
this._isRendered = true;
try {
const eventBus = this.get('eventBus');
if (eventBus) {
// ✅ Fire custom lifecycle event — nothing built-in for this
eventBus.fire('form.rendered', { form: this });
}
} catch (e) {
console.warn('⚠️ [CustomForm] Could not fire form.rendered event:', e);
}
return result;
} catch (error) {
this._isRendered = false;
throw error;
}
}
get isRendered(): boolean {
return this._isRendered;
}
}
Evaluators that need to apply DOM changes after render listen to form.rendered:
// In DisabledEvaluator:
this._eventBus.on('form.rendered', () => {
// ✅ DOM now exists — safe to apply disabled states
setTimeout(() => this._applyDisabledToDOM(), 50);
});
The timeout prevents the handler from running during the same synchronous execution as form.rendered firing, which could conflict with Form-JS's own post-render processing.
Why not use form.init for this? form.init fires before the schema is imported — the DOM doesn't exist yet. DOM manipulation at form.init manipulates nothing.
Why extend importSchema instead of listening to another event? There is no built-in post-render event. importSchema is the only reliable hook for "rendering is complete."
Use 2: Cross-Evaluator State Broadcasting
Events fired: disabled.states.changed, hidden.states.changed, required.states.changed, showLatestValue.states.changed
Where: Each evaluator fires its state event; other systems listen
This is the cleanest use of the event bus as an application communication layer. Each evaluator maintains its own state Map and fires a state-change event when states update. Other classes that need to react — validators, application code, other evaluators — listen to these events.
// In DisabledEvaluator — fires when disabled states change:
if (hasChanges) {
this._applyDisabledToDOM();
this._eventBus.fire('disabled.states.changed', {
states: Object.fromEntries(this._disabledStates)
});
}
// In RequiredEvaluator — fires when required states change:
if (hasChanges) {
this._form._setState({ schema: { ...schema } }); // Trigger re-render
this._eventBus.fire('required.states.changed', {
states: Object.fromEntries(this._requiredStates)
});
}
// In RequiredValidator — listens to required states changing:
this._eventBus.on('required.states.changed', () => {
// Required states changed — re-validate to surface errors
this._applyErrorsToForm();
});
Why fire events instead of calling methods directly?
The naive alternative is direct method calls. RequiredEvaluator could hold a reference to RequiredValidator and call requiredValidator.onRequiredStatesChanged() directly. This works but creates tight coupling — RequiredEvaluator must know that RequiredValidator exists and import it.
With the event bus, RequiredEvaluator knows nothing about RequiredValidator. It fires required.states.changed and moves on. RequiredValidator subscribes to that event and reacts. Neither class needs to know the other exists. You can add a third listener to required.states.changed (an analytics tracker, a debug logger, an application-level handler) without touching RequiredEvaluator at all.
This is the open/closed principle applied to runtime communication: open for extension (add new listeners), closed for modification (don't touch the firing class).
Use 3: Passing Context From Outside the Form
Events fired: ticket.context.set
Where: Application code fires this event to pass ticket data into evaluators running inside the form
Evaluators run inside the Form-JS DI container. They have access to form, eventBus, and other registered services. They do not have access to application-level data — the ticket ID that the surrounding application knows about when it displays the form.
The TicketAutoFillEvaluator needs the current ticket's ID to fetch data for auto-fill. This ID is not in the form schema (it's runtime context, not configuration). It's not in the form data (the user doesn't type it in). It lives in the application layer — the React/Vue/Angular component that hosts the form.
The event bus is the bridge between the application layer and the form internals:
// Application code — outside the form, in the hosting component
const form = new CustomForm({ container: formContainer });
await form.importSchema(schema, data);
// ✅ Pass ticket context INTO the form via the event bus
const eventBus = form.get('eventBus');
eventBus.fire('ticket.context.set', {
ticket_id: currentTicket.id,
ticket_data: currentTicket
});
// Inside TicketAutoFillEvaluator — inside the form DI container
constructor(eventBus, form) {
this._eventBus = eventBus;
this._currentTicketContext = null;
// ✅ Listen for context passed from outside
this._eventBus.on('ticket.context.set', (event) => {
this._currentTicketContext = event;
});
}
_getCurrentTicketId() {
// ✅ Check event-bus-delivered context first
if (this._currentTicketContext?.ticket_id) {
return this._currentTicketContext.ticket_id;
}
// Fallback strategies...
const formData = this._form._state?.data || {};
return formData.ticket_id || formData.ticket?.ticket_id || null;
}
Why the event bus, not a constructor parameter?
The evaluator is instantiated by the DI container. Its constructor parameters are DI-managed services — eventBus, form, formFieldRegistry. You can't pass application-specific data like a ticket ID through the DI system without registering it as a service, which would require knowing the ticket ID at form initialization time.
The event bus approach lets you pass context at any time — after initialization, after the form renders, or when the ticket changes without re-initializing the form.
Why the event bus, not a global variable?
A global would work but breaks when multiple forms exist on the same page. Each form has its own event bus. Firing ticket.context.set on Form A's event bus reaches only Form A's evaluators. A global variable would be shared across all forms.
Use 4: Cross-Framework Data Store
Where: _pendingFilesRef attached to the event bus instance
This is the most unconventional use of the event bus — not as a message passing system, but as a data store. A JavaScript object with properties attached to it.
The problem it solves: file objects selected by the user in a React component need to be accessible to the CustomForm class when the form submits. File objects cannot be stored in form state (they're not serializable JSON). React Context doesn't cross the Preact boundary. A global variable would break with multiple forms.
The event bus instance is the one object that exists in both worlds — it's created by Form-JS (Preact side), accessible through form.get('eventBus') from any DI service, and passable as a prop to React components through the bridge from Article 9.
// CustomForm constructor — attach the store to the event bus instance
constructor(options: FormOptions = {}) {
super(mergedOptions);
try {
const eventBus = this.get('eventBus');
if (eventBus && !eventBus._pendingFilesRef) {
// ✅ Attach a Map to the event bus instance itself
// Not as an event — as a property on the object
eventBus._pendingFilesRef = { current: new Map() };
}
} catch (e) {
console.warn('⚠️ [CustomForm] Could not initialize _pendingFilesRef:', e);
}
}
// FileUpload.tsx (React component) — writes files into the store
useEffect(() => {
if (eventBus?._pendingFilesRef) {
const pendingFilesMap = eventBus._pendingFilesRef.current;
const files = localFiles.map(lf => lf.file);
if (files.length > 0) {
pendingFilesMap.set(fieldKey, files);
} else {
pendingFilesMap.delete(fieldKey);
}
}
}, [localFiles, eventBus, fieldKey]);
// CustomForm — reads files from the store on submit
async uploadPendingFiles(taskId: string) {
const eventBus = this.get('eventBus');
if (!eventBus?._pendingFilesRef) return;
const pendingFilesMap = eventBus._pendingFilesRef.current;
for (const [fieldKey, files] of pendingFilesMap.entries()) {
for (const file of files) {
await this.uploadFileAsAttachment(taskId, file, `File field: ${fieldKey}`);
}
}
pendingFilesMap.clear();
}
Why this works across the React/Preact boundary:
The event bus is a JavaScript object. Properties on JavaScript objects work universally — no framework involvement. When CustomForm attaches _pendingFilesRef to the event bus object, that property is accessible anywhere the event bus object is accessible. It doesn't matter whether the accessor is React code, Preact code, or plain JavaScript.
Form-JS DI container
└── eventBus instance (JavaScript object)
└── _pendingFilesRef property
└── Map of fieldKey → File[]
Accessible from:
✅ CustomForm (plain TypeScript class)
✅ DI services (TicketAutoFillEvaluator, etc.)
✅ React components (passed as prop through bridge)
✅ Preact components (passed as prop)
What doesn't work:
React Context: doesn't cross the Preact boundary. Preact Context: doesn't cross the React boundary. Module-level globals: shared across all form instances on the page — breaks with multiple forms. Component state: isolated to the component tree, not accessible from outside.
Use 5: Label/Value Synchronization
Events fired: dropdown:labelSelected
Where: Custom dropdown component fires; BindingEvaluator listens
Dropdown fields store a value (like "option_1") but display a label (like "Hardware Replacement"). FEEL expressions might need to reference the label, not just the value — for example, = assignee_label to display the human-readable name of the selected assignee.
When a dropdown selection changes, the FormStaticSelectDropdown component knows both the value (which it stores via onChange) and the label (which it displays but doesn't store). The label needs to be made available to FEEL expressions.
// FormStaticSelectDropdown — fires when an option is selected
const handleSelect = (selectedValue) => {
const selectedOption = options.find(opt => opt.value === selectedValue);
if (selectedOption) {
// ✅ Fire event with both value and label
document.dispatchEvent(new CustomEvent('dropdown:labelSelected', {
detail: {
fieldKey: props.inputId, // Field key to associate label with
value: selectedValue,
label: selectedOption.label
}
}));
}
props.onChangeValue(selectedValue);
};
// BindingEvaluator — listens for label selections
if (typeof document !== 'undefined') {
document.addEventListener('dropdown:labelSelected', (event) => {
const { fieldKey, value, label } = event.detail;
if (!fieldKey) {
console.error('[BindingEvaluator] Received dropdown:labelSelected with undefined fieldKey');
return;
}
// ✅ Store label alongside value in form data
const currentData = this._form._state?.data || {};
const newData = {
...currentData,
[fieldKey]: value,
[`${fieldKey}_label`]: label // Label available in FEEL as fieldKey_label
};
this._form._setState({ data: newData });
});
}
Why document events, not eventBus events?
The dropdown components are React components mounted via the bridge from Article 9. They have access to the event bus only if it's passed as a prop — and the bridge passes it through. In practice, using document.dispatchEvent is simpler than ensuring the event bus prop reaches deep into the component tree. The BindingEvaluator listens on document, which always works regardless of where in the component tree the event originated.
This is a pragmatic tradeoff. Ideally the event would go through the form's event bus directly. The document approach works but is harder to test and could theoretically conflict with other page-level listeners.
The eventBus.on / eventBus.off / eventBus.fire API
The API is simple but has a few behaviors worth knowing:
// Subscribe — handler receives the event payload object
const handler = (event) => {
console.log(event.someData);
};
eventBus.on('my.event', handler);
// Unsubscribe — must pass the SAME function reference
eventBus.off('my.event', handler);
// ✅ Works — same reference
// ❌ Won't work: eventBus.off('my.event', (event) => { ... })
// Anonymous functions are different references
// Fire — payload is merged with event metadata
eventBus.fire('my.event', {
someData: 'value',
moreData: 42
});
// Handler receives: { someData: 'value', moreData: 42, type: 'my.event' }
// eventBus adds 'type' to the payload automatically
Priority in eventBus.on:
The event bus supports a priority parameter:
eventBus.on('changed', handler, 1000); // Higher priority — runs first
eventBus.on('changed', handler, 500); // Default priority
eventBus.on('changed', handler, 100); // Lower priority — runs last
I don't use priority in my custom events — I use it only for Form-JS's built-in events where I need to intercept before the default behavior. For custom events, all handlers at the same priority run in registration order.
Cleanup in class-based services:
For DI-managed services (evaluators, validators), you don't need to manually call eventBus.off — the service lives as long as the form instance and the form instance manages cleanup. For document event listeners (like the dropdown:labelSelected listener), you should clean up if the component unmounts:
// In BindingEvaluator — cleanup document listener if form destroys
const handleLabelSelected = (event) => { /* ... */ };
document.addEventListener('dropdown:labelSelected', handleLabelSelected);
// In a destroy/cleanup method:
document.removeEventListener('dropdown:labelSelected', handleLabelSelected);
I don't implement this cleanup because BindingEvaluator lives as long as the form. If the form is destroyed, the evaluator is destroyed too and the listener reference becomes unreachable (effectively cleaned up by garbage collection). For React components that mount and unmount, explicit cleanup is necessary.
The Risks
Risk 1: Event Name Collisions
Custom events share the same namespace as Form-JS's built-in events. If Form-JS ever adds a built-in event called required.states.changed, your RequiredEvaluator that fires an event with the same name would conflict. Both handlers would fire on both events, causing duplicate evaluation runs and potential infinite loops.
The mitigation is namespacing. Instead of:
eventBus.fire('required.states.changed', {...});
Use a namespace that's unlikely to conflict:
eventBus.fire('extension.required.states.changed', {...});
// or
eventBus.fire('myapp.required.states.changed', {...});
I didn't namespace consistently in my implementation — a technical debt item. If you're starting fresh, namespace your custom events from the beginning.
Risk 2: No TypeScript Typing on Custom Events
The event bus is typed generically. When you call:
eventBus.fire('required.states.changed', { states: {...} });
TypeScript doesn't know that required.states.changed events have a states payload. When you listen:
eventBus.on('required.states.changed', (event) => {
const states = event.states; // TypeScript: any
});
event.states is any. Typos in field access are silent runtime bugs.
The mitigation is a typed event registry:
// eventTypes.ts — typed event definitions
export interface FormExtensionEvents {
'required.states.changed': { states: Record<string, boolean> };
'disabled.states.changed': { states: Record<string, boolean> };
'hidden.states.changed': { states: Record<string, boolean> };
'form.rendered': { form: Form };
'ticket.context.set': { ticket_id: string; ticket_data?: any };
'dropdown:labelSelected': { fieldKey: string; value: string; label: string };
}
Then a typed wrapper for eventBus.on and eventBus.fire:
// typedEventBus.ts
import type { FormExtensionEvents } from './eventTypes';
export function fireEvent<K extends keyof FormExtensionEvents>(
eventBus: any,
eventName: K,
payload: FormExtensionEvents[K]
): void {
eventBus.fire(eventName, payload);
}
export function onEvent<K extends keyof FormExtensionEvents>(
eventBus: any,
eventName: K,
handler: (event: FormExtensionEvents[K]) => void
): void {
eventBus.on(eventName, handler);
}
Usage:
import { fireEvent, onEvent } from './typedEventBus';
// ✅ TypeScript knows the payload shape
fireEvent(this._eventBus, 'required.states.changed', {
states: Object.fromEntries(this._requiredStates)
});
onEvent(this._eventBus, 'required.states.changed', (event) => {
const states = event.states; // TypeScript: Record<string, boolean>
});
This pattern adds type safety without requiring changes to the underlying event bus implementation.
Risk 3: Debugging Is Non-Obvious
When a bug involves an event, the call stack shows the event bus dispatch, not the original calling code. If required.states.changed causes an infinite loop, the stack trace shows eventBus.fire → handler → eventBus.fire → handler → ... with no indication of which evaluator initiated the chain.
The mitigation is a debug mode in your evaluators:
// In each evaluator, add a debug flag
this._debug = false; // Set to true to trace event firing
if (this._debug) {
console.group(`[${this.constructor.name}] firing required.states.changed`);
console.log('States:', Object.fromEntries(this._requiredStates));
console.trace(); // Show call stack at fire time
console.groupEnd();
}
this._eventBus.fire('required.states.changed', { states: ... });
The Event Name Registry
A central registry prevents naming conflicts, typos, and "what events does this system fire?" questions from future developers:
// src/formjs/shared/events.ts — single source of truth for all custom events
/**
* All custom events fired by the Form-JS extension system.
*
* Form-JS built-in events (form.init, changed, submit, etc.) are
* NOT listed here — they are documented in the Form-JS source.
*
* NAMING CONVENTION:
* - Lifecycle extensions: form.[name] (e.g., form.rendered)
* - State broadcasts: [property].states.changed (e.g., required.states.changed)
* - Data passing: [scope].[action] (e.g., ticket.context.set)
* - Component communication: [component]:[action] (e.g., dropdown:labelSelected)
*/
export const FORM_EVENTS = {
// =========================================================
// Lifecycle extensions
// =========================================================
/** Fired by CustomForm after importSchema completes and form is rendered */
FORM_RENDERED: 'form.rendered',
// =========================================================
// State broadcasts — fired by evaluators, listened by validators
// =========================================================
/** Fired by RequiredEvaluator when required states change */
REQUIRED_STATES_CHANGED: 'required.states.changed',
/** Fired by DisabledEvaluator when disabled states change */
DISABLED_STATES_CHANGED: 'disabled.states.changed',
/** Fired by HideEvaluator when hidden states change */
HIDDEN_STATES_CHANGED: 'hidden.states.changed',
/** Fired by PersistentEvaluator when persistent states change */
PERSISTENT_STATES_CHANGED: 'persistent.states.changed',
/** Fired by ShowLatestValueEvaluator when showLatestValue states change */
SHOW_LATEST_VALUE_STATES_CHANGED: 'showLatestValue.states.changed',
// =========================================================
// Data passing — fired from outside the form
// =========================================================
/** Fired by application code to pass ticket context into evaluators */
TICKET_CONTEXT_SET: 'ticket.context.set',
// =========================================================
// Component communication
// =========================================================
/** Fired by dropdown components when a selection includes a label */
DROPDOWN_LABEL_SELECTED: 'dropdown:labelSelected',
// =========================================================
// Editor events
// =========================================================
/** Fired by the field was updated in the editor */
FIELD_UPDATED: 'field.updated',
} as const;
// Type-safe event name type
export type FormEventName = typeof FORM_EVENTS[keyof typeof FORM_EVENTS];
Usage:
import { FORM_EVENTS } from '@/formjs/shared/events';
// ✅ No string literals — typos are compile-time errors
this._eventBus.fire(FORM_EVENTS.REQUIRED_STATES_CHANGED, {
states: Object.fromEntries(this._requiredStates)
});
this._eventBus.on(FORM_EVENTS.REQUIRED_STATES_CHANGED, () => {
this._applyErrorsToForm();
});
Combined with the typed event bus wrapper:
// The complete pattern
fireEvent(this._eventBus, FORM_EVENTS.REQUIRED_STATES_CHANGED, {
states: Object.fromEntries(this._requiredStates)
// TypeScript validates this matches the FormExtensionEvents definition
});
All Custom Event Uses at a Glance
Here is every custom event in the system, who fires it, and who listens:
| Event | Fired By | Listened By | Purpose |
|---|---|---|---|
form.rendered |
CustomForm.importSchema |
DisabledEvaluator, HideEvaluator, all DOM evaluators |
Signal that DOM exists |
required.states.changed |
RequiredEvaluator |
RequiredValidator |
Trigger re-validation when required states change |
disabled.states.changed |
DisabledEvaluator |
Application code (optional) | Inform app of disabled field states |
hidden.states.changed |
HideEvaluator |
Application code (optional) | Inform app of hidden field states |
persistent.states.changed |
PersistentEvaluator |
Application code | Inform app which fields to persist |
showLatestValue.states.changed |
ShowLatestValueEvaluator |
Application code | Inform app which fields show latest value |
ticket.context.set |
Application code | TicketAutoFillEvaluator |
Pass ticket context into evaluators |
dropdown:labelSelected |
FormStaticSelectDropdown |
BindingEvaluator |
Store label alongside value in form data |
field.updated |
TicketAutoFillEvaluator |
Field renderers | Signal option list changes after auto-fill |
The Tradeoffs
The event bus is a shared mutable object. Attaching _pendingFilesRef to the event bus instance works but violates the principle that the event bus should only pass messages, not store data. If a future version of Form-JS uses _pendingFilesRef internally for something else, you have a silent collision. A safer alternative is to register a custom service in the DI container:
// Safer alternative: register as a DI service
const PendingFilesStore = {
files: new Map()
};
// In CustomForm or module:
export const PendingFilesModule = {
pendingFilesStore: ['value', PendingFilesStore]
};
// Access from evaluators via injection:
class FileAwareEvaluator {
constructor(eventBus, form, pendingFilesStore) {
this._pendingFilesStore = pendingFilesStore;
}
}
FileAwareEvaluator.$inject = ['eventBus', 'form', 'pendingFilesStore'];
This approach is cleaner architecturally. I used the event bus instance approach because React components don't have access to the DI container — they receive the event bus as a prop through the bridge, but the DI container itself is not accessible from React code. The event bus instance approach works across the React/Preact boundary because it's just a property on a JavaScript object. The DI approach would require a separate mechanism to pass the store to React components.
Event ordering is implicit. When required.states.changed fires, all listeners run. The order depends on registration order (and priority, if set). If RequiredValidator._applyErrorsToForm runs before RequiredEvaluator has finished writing its state, you get stale state in the validation. The setTimeout-based debouncing in evaluators helps, but event ordering across multiple listeners is not guaranteed to be deterministic without explicit priority.
Custom events are invisible to Form-JS tooling. Form-JS's devtools (if you use them) show built-in events in their event log. Your custom events don't appear. When debugging, you have to add your own logging.
What Comes Next
The event bus ties all the evaluators together without requiring them to know about each other. This loose coupling makes the system extensible — adding a new evaluator means subscribing to existing events, not modifying existing classes.
Article 16 covers the final piece of the architecture: the CustomForm and CustomFormEditor subclasses that bootstrap all these evaluators, provide the _pendingFilesRef store, fire form.rendered, and expose the file upload API. How everything connects into a deployable whole.
This is Part 15 of "Extending bpmn-io Form-JS Beyond Its Limits." The series covers the complete architecture for production-grade Form-JS extensions — the documentation that doesn't exist yet.
Tags: camunda bpmn formjs event-bus architecture loose-coupling javascript devex
Top comments (0)