Part 1 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
I spent three days staring at a Cannot read properties of undefined error before I understood what was actually happening. I was trying to build a custom field for bpmn-io's Form-JS — a grid component with Excel import — and I could not get my service to be available where I needed it. I tried passing things through props. I tried module-level variables. I tried window globals. All of it felt wrong, and none of it explained why the official examples worked while mine didn't.
The answer was a dependency injection system that the official documentation mentions in passing but never actually explains. This article is what I wish existed when I started.
The Problem
Form-JS is built on top of diagram-js, which uses a custom IoC (Inversion of Control) container called didi. Every service, evaluator, renderer, and properties panel provider in the entire ecosystem is wired together through this container.
When you look at the form-js source code, you see patterns like this everywhere:
MyService.$inject = ['eventBus', 'form'];
And module definitions like this:
export default {
__init__: ['myService'],
myService: ['type', MyService]
};
The official docs show these patterns in examples but never explain what they mean, what the rules are, when things break, or why. The result is that every developer who tries to extend Form-JS independently rediscovers the same system through trial and error.
What I Tried First (And Why It Failed)
My first attempt looked like this. I needed access to the eventBus in my evaluator, so I tried to import it:
// ❌ This doesn't work
import { eventBus } from '@bpmn-io/form-js';
export class MyEvaluator {
constructor() {
eventBus.on('form.init', () => { ... });
}
}
There is no eventBus export. The event bus is an instance created per-form, not a singleton you can import.
My second attempt was to pass it through a prop chain:
// ❌ This works but breaks as soon as you need it in a non-component context
<MyRenderer eventBus={eventBus} />
This falls apart immediately when you need the event bus in a class that isn't a component — like a validator or a state manager. You end up passing things everywhere, and the more you build, the worse it gets.
My third attempt was a module-level variable that I set during initialization:
// ❌ This breaks with multiple form instances on the same page
let _eventBus = null;
export function setEventBus(bus) {
_eventBus = bus;
}
This fails the moment you have two forms on the same page because the second form overwrites the first's event bus.
The correct answer is the IoC container — and once you understand it, everything else in Form-JS makes sense.
The Solution: How the IoC Container Actually Works
The Container's Job
The didi IoC container is responsible for creating and managing every service in Form-JS. When you instantiate a form:
const form = new Form({ container: document.getElementById('form') });
Under the hood, Form-JS creates a didi container and registers every built-in module into it. Each module declares what it provides and what it needs. The container resolves all dependencies automatically before anything runs.
Your job as an extension developer is to declare your own services in the same way and tell Form-JS to include them.
The Three Things You Need to Understand
1. $inject — Declaring Your Dependencies
$inject is a static array property on your class that lists the names of services your class needs. The container reads this array and passes the matching services as constructor arguments, in order:
export class MyEvaluator {
constructor(eventBus, form) {
this._eventBus = eventBus;
this._form = form;
this._eventBus.on('form.init', () => {
console.log('Form initialized');
});
}
}
// ✅ This is how the container knows what to inject
MyEvaluator.$inject = ['eventBus', 'form'];
The parameter names in the constructor (eventBus, form) do not matter — only the order matters. The container injects services in the same order as the $inject array. You could write:
constructor(bus, theForm) { ... }
MyEvaluator.$inject = ['eventBus', 'form'];
And bus would receive the event bus and theForm would receive the form. This is a common source of confusion when reading other people's code.
Available built-in services you can inject:
| Service Name | What It Is |
|---|---|
eventBus |
The form's internal event system |
form |
The form instance itself |
formFieldRegistry |
Registry of all field instances |
formFields |
The field type registry (for registering custom types) |
propertiesPanel |
The editor's properties panel (editor only) |
formEditor |
The editor instance (editor only) |
debounceInput |
Debounce utility for input handlers |
2. The Module Definition — What You Provide
A module is a plain JavaScript object that tells the container what services you're registering and how to create them:
export default {
__init__: ['myEvaluator'],
myEvaluator: ['type', MyEvaluator]
};
Let's break this down:
__init__ is an array of service names that should be instantiated immediately when the module loads. Services NOT listed here are lazy — they're only created when something requests them. For evaluators and validators that subscribe to events, you must list them in __init__. If you don't, your event listeners never get registered because the class never gets instantiated.
// ❌ MyEvaluator never gets created, eventBus.on never runs
export default {
myEvaluator: ['type', MyEvaluator]
};
// ✅ MyEvaluator is created on module load, eventBus.on runs immediately
export default {
__init__: ['myEvaluator'],
myEvaluator: ['type', MyEvaluator]
};
['type', MyClass] tells the container to create an instance of MyClass when the service is needed. The container calls new MyClass(...dependencies) automatically.
There are three registration types:
export default {
// Creates an instance via new MyClass(dependencies)
myService: ['type', MyClass],
// Uses the value directly — no instantiation
myConfig: ['value', { timeout: 3000, debug: false }],
// Calls the function with dependencies and uses the return value
myFactory: ['factory', function(eventBus) {
return eventBus ? new MyClass() : new FallbackClass();
}]
};
For almost all cases you'll use ['type', MyClass]. Use ['value', ...] for configuration objects. Use ['factory', ...] when construction logic depends on runtime conditions.
3. registerProvider — The Properties Panel Priority System
If you're extending the properties panel (the sidebar in the editor), you use a different registration mechanism:
export class MyPropertiesProvider {
constructor(propertiesPanel, eventBus) {
propertiesPanel.registerProvider(this, 500);
this._eventBus = eventBus;
}
getGroups(field, editField) {
return (groups) => {
// Modify groups here
return groups;
};
}
}
MyPropertiesProvider.$inject = ['propertiesPanel', 'eventBus'];
The second argument to registerProvider is the priority. This controls the order in which providers run when the panel renders.
Higher priority = runs later = can see and modify what earlier providers added.
Form-JS's built-in providers register at priority 500. If you register at 500, you run alongside the built-ins (in undefined order relative to each other). If you register at 1000, you run after the built-ins and can modify or remove their entries.
This is critical for overriding defaults:
// ✅ Priority 1000 — runs after built-ins at 500
// This provider can see what the built-ins added and remove/replace entries
export class MyDisabledProvider {
constructor(propertiesPanel) {
propertiesPanel.registerProvider(this, 1000);
}
getGroups(element, editField) {
return (groups) => {
// Remove the default 'disabled' entry
groups.forEach(group => {
if (group?.entries) {
group.entries = group.entries.filter(e => e.id !== 'disabled');
}
});
// Add our enhanced version
// ...
return groups;
};
}
}
Notice that getGroups returns a function, not the groups themselves. This is the middleware pattern — each provider receives the groups from the previous provider and passes modified groups to the next. This is not documented anywhere.
Putting It Together: A Complete Working Module
Here is the minimal working module for a custom runtime evaluator:
// MyEvaluator.js
export class MyEvaluator {
constructor(eventBus, form) {
this._eventBus = eventBus;
this._form = form;
this._initialized = false;
// Subscribe to events in the constructor
// The container guarantees dependencies are ready before the constructor runs
this._eventBus.on('form.init', () => {
this._initialized = true;
this.evaluateAll();
});
this._eventBus.on('changed', () => {
if (!this._initialized) return;
this.evaluateAll();
});
}
evaluateAll() {
const schema = this._form._state?.schema;
const data = this._form._state?.data || {};
if (!schema) return;
// Your evaluation logic here
console.log('Evaluating with data:', data);
}
}
// ✅ Required — declares dependencies by name
MyEvaluator.$inject = ['eventBus', 'form'];
// MyEvaluatorModule.js
import { MyEvaluator } from './MyEvaluator';
export default {
__init__: ['myEvaluator'], // ✅ Instantiate immediately
myEvaluator: ['type', MyEvaluator]
};
// index.js — Register with your form
import { Form } from '@bpmn-io/form-js';
import MyEvaluatorModule from './MyEvaluatorModule';
const form = new Form({
container: document.getElementById('form'),
additionalModules: [
MyEvaluatorModule
]
});
await form.importSchema(schema, data);
That's the complete pattern. Three files, one working evaluator.
The Properties Panel Provider Template
For properties panel extensions, the module is slightly different:
// MyPropertiesProvider.js
export class MyPropertiesProvider {
constructor(propertiesPanel, eventBus) {
propertiesPanel.registerProvider(this, 500);
this._eventBus = eventBus;
}
getGroups(field, editField) {
// getGroups returns a FUNCTION (middleware pattern)
return (groups) => {
// Only modify for specific field types
if (field.type !== 'textfield') {
return groups;
}
// Find an existing group
const generalGroup = groups.find(g => g.id === 'general');
if (!generalGroup) return groups;
// Add an entry to the group
generalGroup.entries.push({
id: `my-entry-${field.id}`, // Must be unique
component: MyEntryComponent, // The UI component
field,
editField,
isEdited: (element) => { // Controls the blue dot indicator
return !!element.myProperty;
}
});
return groups;
};
}
}
MyPropertiesProvider.$inject = ['propertiesPanel', 'eventBus'];
// MyPropertiesPanelModule.js
import { MyPropertiesProvider } from './MyPropertiesProvider';
export default {
__init__: ['myPropertiesProvider'],
myPropertiesProvider: ['type', MyPropertiesProvider]
};
// Register with the editor
import { FormEditor } from '@bpmn-io/form-js';
import MyPropertiesPanelModule from './MyPropertiesPanelModule';
const editor = new FormEditor({
container: document.getElementById('editor'),
additionalModules: [
MyPropertiesPanelModule
]
});
The Mistakes Everyone Makes
Forgetting $inject entirely. If you omit $inject, the container cannot inject dependencies. Your constructor will receive undefined for every parameter. The error won't tell you this directly — you'll see Cannot read properties of undefined (reading 'on') when you try to call this._eventBus.on(...).
Forgetting __init__. If your service subscribes to events in its constructor but you don't list it in __init__, it never gets instantiated and your event listeners never run. The form loads without errors and your evaluator silently does nothing.
Duplicate entry IDs. If two entries in the properties panel share the same id, the second one silently replaces the first — no error, no warning. Always suffix entry IDs with the field's ID:
// ❌ Breaks when two textfields are in the same form
id: 'my-custom-entry'
// ✅ Unique per field instance
id: `my-custom-entry-${field.id}`
Not using the middleware pattern. getGroups must return a function that takes groups and returns groups. Returning the groups directly causes a silent failure:
// ❌ Silent failure — the panel doesn't update
getGroups(field, editField) {
const groups = [];
// modify groups...
return groups;
}
// ✅ Correct middleware pattern
getGroups(field, editField) {
return (groups) => {
// modify groups...
return groups;
};
}
Injecting editor-only services in runtime modules. propertiesPanel and formEditor only exist in the editor context. If you inject them in a module you also use in the runtime form (not the editor), you'll get an injection error. Use useService('propertiesPanel', false) with the optional flag, or split your modules.
The Tradeoffs
The IoC system is powerful but it has costs.
No TypeScript types for injected services. The container resolves services at runtime by string name. Your constructor parameters are typed as any unless you add manual type annotations. Form-JS does not ship types for its internal services.
No compile-time validation. If you misspell a service name in $inject, you get undefined at runtime — not a build error. The string 'eventBuss' fails silently.
Debugging is non-obvious. When injection fails, the error points to the constructor where you tried to use the undefined value, not to the $inject declaration where the problem is. You learn to check $inject first whenever you see Cannot read properties of undefined.
The priority system has no conflict detection. If two providers both try to replace the same entry at the same priority, the last one wins based on registration order — which is determined by the order modules appear in additionalModules. This is not documented and not guaranteed to be stable.
These are manageable tradeoffs once you know they exist. The system is fundamentally sound — the same IoC pattern powers diagram-js, bpmn-js, and every other tool in the bpmn-io ecosystem. Learning it once pays dividends across the entire platform.
What Comes Next
Now that you understand how services are wired together, the rest of Form-JS extension development follows a consistent pattern. Every article in this series uses the module system described here as its foundation.
In Article 2, we use this foundation to build a complete custom field type — renderer, properties panel, validator, and module export — and understand what each layer is responsible for.
In Article 5, we use registerProvider with priority 1000 to override Form-JS's default disabled, readonly, and required entries and replace them with ones that support FEEL expressions.
Found this useful? The entire series covering Form-JS extensions, FEEL runtime evaluation, and custom field types is being published weekly. Follow along if you're building serious extensions on the bpmn-io platform.
Tags: camunda bpmn formjs dependency-injection form-builder javascript devex
Top comments (0)