Part 2 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
Every tutorial about custom Form-JS fields shows you the same thing: render a component, return some HTML, done. What they don't show you is what happens after. How does the editor know your field exists? How does the properties panel know what configuration options to show? How does validation know to run your rules when the form submits? How does the form lifecycle know to initialize your field properly?
I found this out the hard way when I built a grid field — a spreadsheet-like component with Excel import, cell-level validation, and dynamic columns. The render part took a day. Connecting it to the rest of Form-JS took a week, because I was discovering a four-layer architecture that nobody had documented.
This article documents all four layers so you don't spend that week.
The Problem
The official Form-JS documentation for custom fields stops at the renderer. It shows you how to register a component that renders HTML when the field type appears in the schema. That's Layer 1 of 4.
What's missing:
- Layer 2: How the editor's properties panel shows configuration options for your field
- Layer 3: How custom validation runs at the right time — both real-time feedback and submit-time errors
- Layer 4: How all of this gets wired together through the module system
Without all four layers, your custom field works in isolation but breaks in practice. The editor has no way to configure it. Validation doesn't run on submit. The form lifecycle ignores it.
What I Tried First
When I first built the grid field, I started with just a renderer and a properties panel entry, and I put them in the same file. It worked in the editor. Then I added validation — and I put that in the same file too. Then I added the module export. Within a month the file was 800 lines and every change risked breaking something unrelated.
The second problem was subtler. I put my validation logic directly in the renderer component. It worked for real-time feedback — cells turned red as the user typed. But on form submit, nothing. The submit handler called form.validate() and my cell errors weren't included in the result because I'd never hooked into form.validate(). I had UX validation without actual validation.
The four-layer architecture I'm about to describe solves both problems: separation of concerns and correct integration with the form lifecycle.
The Solution: Four Layers, Four Responsibilities
Here is the file structure before I explain each layer:
src/formjs/fieldTypes/gridfield/
├── render/
│ ├── index.js ← Layer 1: Render Extension
│ └── gridfield-styles.css
├── propertiesPanel/
│ └── index.js ← Layer 2: Properties Panel Extension
├── validation/
│ ├── GridFieldValidationEvaluator.js ← Layer 3a: Real-time Evaluator
│ └── GridFieldValidationValidator.js ← Layer 3b: Submit-time Validator
└── GridFieldModule.js ← Layer 4: Module Export
Each file has exactly one responsibility. They connect through the DI system from Article 1.
Layer 1: The Render Extension
The render extension is your field's visual representation. It's what the user sees and interacts with in the form.
The config Static Property
Every custom field renderer must have a config static property. This is how Form-JS learns about your field type:
// render/index.js
import { html, useContext } from 'diagram-js/lib/ui';
import { FormContext, Label, Errors, Description } from '@bpmn-io/form-js';
export const gridType = 'gridfield';
export function GridFieldRenderer(props) {
const {
disabled,
errors = [],
field,
readonly,
value
} = props;
const { formId } = useContext(FormContext);
// Your render logic here
return html`
<div class="fjs-form-field fjs-form-field-gridfield">
<${Label}
id=${`fjs-form-${formId}-${field.id}`}
label=${field.label}
required=${field.validate?.required || false} />
<div class="gridfield-container">
<!-- Your custom field UI -->
<p>Grid Field: ${field.key}</p>
</div>
<${Description} description=${field.description} />
<${Errors} errors=${errors} id=${`fjs-form-${formId}-${field.id}-error`} />
</div>
`;
}
// ✅ This is the most important part — the config tells Form-JS everything
// it needs to know about your field type
GridFieldRenderer.config = {
type: gridType, // The type string used in the schema
label: 'Grid', // Label shown in the editor palette
group: 'basic-input', // Which palette group to appear in
keyed: true, // ✅ This field stores a value — it has a key
iconUrl: `data:image/svg+xml,${encodeURIComponent(GridIcon)}`,
// Which built-in properties panel entries to show
// List ONLY the ones that make sense for your field
propertiesPanelEntries: [
'key',
'label',
'description',
'readonly',
'disabled'
],
// ✅ Factory function — returns the default schema object when dragged onto the form
create(options = {}) {
return {
type: gridType,
label: options.label || 'Grid',
key: options.key || undefined,
grid_config: {
dynamic_grid: false,
grid_columns: '',
add_row_disabled: false,
remove_row_disabled: false,
validation_rules: []
},
...options
};
}
};
Let me explain the non-obvious parts:
keyed: true means this field stores a value that gets submitted with the form. Fields like labels or dividers are keyed: false. If your field collects data, it must be keyed: true.
propertiesPanelEntries controls which built-in entries appear in the properties panel. You don't want all of them — a grid field doesn't need a placeholder or defaultValue. List only what makes sense. Your Layer 2 extension adds the rest.
create is a factory function that returns the default schema object when a user drags your field onto the form canvas. Every property your field uses should have a default value here. If you forget to set a default, your field will receive undefined for that property on first render and you'll spend 30 minutes debugging a non-error.
Registering the Renderer
The renderer doesn't use $inject because it's not a class — it's a functional component. Instead it registers itself differently:
// In your module (Layer 4), you register it like this:
export default {
__init__: ['gridFieldRenderer'],
gridFieldRenderer: ['type', GridFieldRendererExtension]
};
But actually, custom field renderers use formFields.register:
// The render extension class wraps the registration
class GridFieldRendererExtension {
constructor(formFields) {
formFields.register(gridType, GridFieldRenderer);
}
}
GridFieldRendererExtension.$inject = ['formFields'];
This is the pattern: a thin class that injects formFields and calls formFields.register(type, component). The actual component is just a function.
The onChange Pattern
Your renderer receives an onChange prop. Call it whenever the field value changes:
export function GridFieldRenderer(props) {
const { field, value, onChange } = props;
const handleCellInput = (rowIndex, col, newValue) => {
const updatedValue = updateGrid(value, rowIndex, col, newValue);
// ✅ Always call onChange with this exact shape
onChange({
field,
value: updatedValue
});
};
// ...
}
The field reference in the onChange payload is important — Form-JS uses it to identify which field changed. Never omit it.
Layer 2: The Properties Panel Extension
The properties panel extension adds configuration UI to the editor's sidebar. This is where form designers configure your field's behavior.
// propertiesPanel/index.js
import { get } from 'min-dash';
import {
isTextFieldEntryEdited,
isCheckboxEntryEdited
} from '@bpmn-io/properties-panel';
export class GridFieldPropertiesProvider {
constructor(propertiesPanel) {
// ✅ Register at priority 500 — same as built-ins
// Use 1000 if you need to modify what built-ins already added
propertiesPanel.registerProvider(this, 500);
}
getGroups(field, editField) {
// ✅ The middleware pattern — return a FUNCTION, not the groups
return (groups) => {
// Only modify the panel for your field type
if (field.type !== 'gridfield') {
return groups;
}
// Find the general group
const generalGroup = groups.find(g => g.id === 'general');
if (!generalGroup) return groups;
// Add your entries to the general group
generalGroup.entries.push(
...createGridConfigEntries(field, editField)
);
return groups;
};
}
}
GridFieldPropertiesProvider.$inject = ['propertiesPanel'];
Building the Entries
Each entry in the properties panel is an object with a specific shape:
function createGridConfigEntries(field, editField) {
// Helper: save a value into the field's grid_config object
const onChange = (key) => (value) => {
const currentConfig = get(field, ['grid_config'], {});
editField(field, 'grid_config', {
...currentConfig,
[key]: value
});
};
// Helper: read a value from the field's grid_config object
const getValue = (key) => () => {
return get(field, ['grid_config', key]);
};
return [
{
// ✅ ID must be unique across ALL entries in the panel
// Suffix with field.id to prevent collisions
id: `grid-columns-${field.id}`,
// The UI component to render for this entry
component: GridColumnsEntry,
// Passes the current value to the component
getValue: getValue('grid_columns'),
// Called when the user changes the value
setValue: onChange('grid_columns'),
// The field being edited — passed to the component as props
field,
// ✅ Controls the blue dot indicator in the panel sidebar
// Return true when this property has a non-default value
isEdited: isTextFieldEntryEdited
},
{
id: `dynamic-grid-${field.id}`,
component: DynamicGridEntry,
getValue: getValue('dynamic_grid'),
setValue: onChange('dynamic_grid'),
field,
isEdited: isCheckboxEntryEdited
}
];
}
Writing an Entry Component
Entry components are Preact functional components that receive props from the entry definition:
import { TextFieldEntry } from '@bpmn-io/properties-panel';
import { useService } from '@bpmn-io/form-js';
function GridColumnsEntry(props) {
const { field, id, getValue, setValue } = props;
// ✅ Always get debounce from the service — don't create your own
const debounce = useService('debounceInput') ?? ((fn) => fn);
return TextFieldEntry({
debounce,
element: field,
getValue,
id,
label: 'Column Names',
description: 'Comma-separated column names (e.g., Name, Age, Department)',
setValue
});
}
A critical point: entry components receive getValue and setValue as props, not as functions they call themselves. The entry definition object is the bridge between your data and your component. The component just renders UI and calls setValue when something changes.
Layer 3: Validation — The Part Every Tutorial Misses
This is where custom field development gets interesting and where most implementations fall short. You need two separate classes.
Why Two Classes?
Real-time validation (the Evaluator) runs continuously as the user types. It's responsible for visual feedback — red cell borders, inline error messages, validation summaries. It runs on the changed event.
Submit-time validation (the Validator) runs when form.validate() is called — which happens on form submission. It's responsible for blocking submission when validation fails. It hooks into form.validate().
If you only build one, you get either feedback without enforcement (user sees errors but can submit anyway) or enforcement without feedback (form blocks submission but user doesn't know why until they try to submit).
Layer 3a: The Evaluator (Real-Time)
// validation/GridFieldValidationEvaluator.js
export class GridFieldValidationEvaluator {
constructor(eventBus, form, formFieldRegistry) {
this._eventBus = eventBus;
this._form = form;
this._formFieldRegistry = formFieldRegistry;
this._validationErrors = {};
this._initialized = false;
// Initialize once the form is ready
this._eventBus.on('form.init', () => {
this._initialized = true;
setTimeout(() => this.evaluateAll(), 50);
});
// Re-evaluate when any field changes
// ✅ Optimization: only run if a gridfield changed
this._eventBus.on('changed', (event) => {
if (!this._initialized) return;
const { field } = event;
if (!field || this._isGridField(field.id)) {
setTimeout(() => this.evaluateAll(), 10);
}
});
}
_isGridField(fieldId) {
const schema = this._form._state?.schema;
if (!schema) return false;
const components = this._getAllComponents(schema.components || []);
const component = components.find(c => c.id === fieldId);
return component?.type === 'gridfield';
}
_getAllComponents(components = []) {
const all = [];
for (const component of components) {
all.push(component);
if (component.type === 'group' && Array.isArray(component.components)) {
all.push(...this._getAllComponents(component.components));
}
}
return all;
}
evaluateAll() {
const schema = this._form._state?.schema;
const data = this._form._state?.data || {};
if (!schema) return;
const components = this._getAllComponents(schema.components || []);
const gridComponents = components.filter(c => c.type === 'gridfield' && c.key);
const newErrors = {};
for (const component of gridComponents) {
const gridValue = data[component.key];
const validationRules = component.grid_config?.validation_rules || [];
if (!validationRules.length || !gridValue) continue;
// Convert stored format to rows
const { rows, columns } = this._valueToRows(gridValue, component.grid_config);
const cellErrors = [];
rows.forEach((row, rowIndex) => {
columns.forEach(col => {
const error = this._validateCell(rowIndex, col, row[col], validationRules, row);
if (error) {
cellErrors.push({ row: rowIndex, column: col, message: error });
}
});
});
if (cellErrors.length > 0) {
// ✅ Key by component.id (not key) because form errors are keyed by field ID
newErrors[component.id] = [`Grid has ${cellErrors.length} validation error(s)`];
}
}
// ✅ Only update state if errors changed — prevents infinite re-render loops
const previousErrors = JSON.stringify(this._validationErrors);
const nextErrors = JSON.stringify(newErrors);
if (previousErrors !== nextErrors) {
this._validationErrors = newErrors;
this._updateFormErrors(newErrors);
}
}
_validateCell(rowIndex, colName, cellValue, rules, row) {
const applicableRules = rules.filter(rule => {
if (!rule.columns) return false;
return rule.columns.split(',').map(c => c.trim()).includes(colName);
});
for (const rule of applicableRules) {
if (rule.isRequired) {
const isEmpty = cellValue === undefined || cellValue === null ||
String(cellValue).trim() === '';
if (isEmpty) return rule.message || 'This field is required';
}
if (rule.regex) {
const regex = new RegExp(rule.regex);
const val = String(cellValue || '');
if (val && !regex.test(val)) {
return rule.message || `Value does not match pattern: ${rule.regex}`;
}
}
}
return null;
}
_updateFormErrors(newErrors) {
const currentErrors = { ...(this._form._state?.errors || {}) };
// Remove old grid errors
for (const fieldId of Object.keys(this._validationErrors)) {
if (!newErrors[fieldId]) {
delete currentErrors[fieldId];
}
}
// Add new grid errors
for (const [fieldId, errors] of Object.entries(newErrors)) {
currentErrors[fieldId] = errors;
}
this._form._setState({ errors: currentErrors });
}
// Called by the Validator to get current errors
getValidationErrors() {
return { ...this._validationErrors };
}
_valueToRows(value, gridConfig) {
// Convert stored column-array format to row-object format
// { ColA: ['val1', 'val2'], ColB: ['val3', 'val4'] }
// becomes [{ ColA: 'val1', ColB: 'val3' }, { ColA: 'val2', ColB: 'val4' }]
const columns = gridConfig.grid_columns
? gridConfig.grid_columns.split(',').map(c => c.trim()).filter(Boolean)
: Object.keys(value).filter(k => k !== '__cols__order__');
const rows = [];
if (columns.length > 0 && value[columns[0]]) {
const rowCount = value[columns[0]].length;
for (let i = 0; i < rowCount; i++) {
const row = {};
columns.forEach(col => { row[col] = value[col]?.[i] ?? ''; });
rows.push(row);
}
}
return { rows, columns };
}
}
GridFieldValidationEvaluator.$inject = ['eventBus', 'form', 'formFieldRegistry'];
Layer 3b: The Validator (Submit-Time)
// validation/GridFieldValidationValidator.js
export class GridFieldValidationValidator {
constructor(eventBus, form, gridFieldValidationEvaluator) {
this._eventBus = eventBus;
this._form = form;
this._evaluator = gridFieldValidationEvaluator;
// Hook into validation once the form exists
this._eventBus.on('form.init', () => {
setTimeout(() => this._hookIntoValidation(), 100);
});
}
_hookIntoValidation() {
if (!this._form?.validate) return;
// ✅ The wrapping pattern — preserve original, add yours
const originalValidate = this._form.validate.bind(this._form);
this._form.validate = () => {
// ✅ Force the evaluator to run NOW with current data
// (it may not have run yet if data was just changed)
this._evaluator.evaluateAll();
// Run original form-js validation
const originalErrors = originalValidate() || {};
// Get our grid validation errors
const gridErrors = this._evaluator.getValidationErrors();
// ✅ Merge — don't replace
const merged = { ...originalErrors };
for (const [fieldId, errorArray] of Object.entries(gridErrors)) {
if (!merged[fieldId]) {
merged[fieldId] = errorArray;
} else if (Array.isArray(merged[fieldId])) {
merged[fieldId] = [...merged[fieldId], ...errorArray];
} else {
merged[fieldId] = [merged[fieldId], ...errorArray];
}
}
// ✅ Surface errors in the form UI
this._form._setState({ errors: merged });
return merged;
};
}
}
GridFieldValidationValidator.$inject = [
'eventBus',
'form',
'gridFieldValidationEvaluator' // ✅ Inject the evaluator by name
];
Notice that the Validator injects the Evaluator by its service name. This works because both are registered in the same module. The Validator doesn't import the Evaluator class directly — the container handles the dependency.
Layer 4: The Module Export
The module export wires all three layers together into a single object that Form-JS can consume:
// GridFieldModule.js
import { GridFieldRenderer } from './render/index.js';
import { GridFieldPropertiesProvider } from './propertiesPanel/index.js';
import { GridFieldValidationEvaluator } from './validation/GridFieldValidationEvaluator.js';
import { GridFieldValidationValidator } from './validation/GridFieldValidationValidator.js';
// ✅ Thin registration classes for the renderer and properties provider
class GridFieldRendererExtension {
constructor(formFields) {
formFields.register('gridfield', GridFieldRenderer);
}
}
GridFieldRendererExtension.$inject = ['formFields'];
// Runtime module — used in the form player
export const GridFieldRenderExtension = {
__init__: [
'gridFieldRendererExtension',
'gridFieldValidationEvaluator',
'gridFieldValidationValidator'
],
gridFieldRendererExtension: ['type', GridFieldRendererExtension],
gridFieldValidationEvaluator: ['type', GridFieldValidationEvaluator],
gridFieldValidationValidator: ['type', GridFieldValidationValidator]
};
// Editor module — used in the form editor
// Includes the properties panel, which only makes sense in the editor
export const GridFieldPropertiesPanelExtension = {
__init__: ['gridFieldPropertiesProvider'],
gridFieldPropertiesProvider: ['type', GridFieldPropertiesProvider]
};
Notice the split into two separate module exports. This is important:
-
GridFieldRenderExtensiongoes into the runtime form (additionalModulesinnew Form(...)) -
GridFieldPropertiesPanelExtensiongoes into the form editor (additionalModulesinnew FormEditor(...))
The runtime form does not need the properties panel. The editor needs both the renderer (to preview the field) and the properties panel.
Registering With the Form
import { Form } from '@bpmn-io/form-js';
import { GridFieldRenderExtension } from './GridFieldModule.js';
// Runtime form
const form = new Form({
container: document.getElementById('form'),
additionalModules: [
GridFieldRenderExtension
]
});
import { FormEditor } from '@bpmn-io/form-js';
import {
GridFieldRenderExtension,
GridFieldPropertiesPanelExtension
} from './GridFieldModule.js';
// Editor
const editor = new FormEditor({
container: document.getElementById('editor'),
additionalModules: [
GridFieldRenderExtension, // For preview rendering
GridFieldPropertiesPanelExtension // For configuration UI
]
});
The Complete Minimal Template
Here is the minimum viable custom field you can copy and adapt. It has all four layers, no extra complexity:
// ============================================================
// Layer 1: Renderer (render/index.js)
// ============================================================
import { html, useContext } from 'diagram-js/lib/ui';
import { FormContext, Label, Errors, Description } from '@bpmn-io/form-js';
export const myFieldType = 'myfield';
export function MyFieldRenderer(props) {
const { field, errors = [], value, onChange, disabled, readonly } = props;
const { formId } = useContext(FormContext);
const fieldId = `fjs-form-${formId}-${field.id}`;
const handleChange = (e) => {
onChange({ field, value: e.target.value });
};
return html`
<div class="fjs-form-field">
<${Label} id=${fieldId} label=${field.label} required=${field.validate?.required} />
<input
id=${fieldId}
type="text"
value=${value || ''}
onInput=${handleChange}
disabled=${disabled || readonly}
/>
<${Description} description=${field.description} />
<${Errors} errors=${errors} />
</div>
`;
}
MyFieldRenderer.config = {
type: myFieldType,
label: 'My Field',
group: 'basic-input',
keyed: true,
propertiesPanelEntries: ['key', 'label', 'description'],
create: (options = {}) => ({
type: myFieldType,
label: options.label || 'My Field',
key: options.key || undefined,
my_config: { option1: '' },
...options
})
};
// ============================================================
// Layer 2: Properties Panel (propertiesPanel/index.js)
// ============================================================
import { get } from 'min-dash';
import { isTextFieldEntryEdited } from '@bpmn-io/properties-panel';
import { TextFieldEntry } from '@bpmn-io/properties-panel';
import { useService } from '@bpmn-io/form-js';
export class MyFieldPropertiesProvider {
constructor(propertiesPanel) {
propertiesPanel.registerProvider(this, 500);
}
getGroups(field, editField) {
return (groups) => {
if (field.type !== myFieldType) return groups;
const generalGroup = groups.find(g => g.id === 'general');
if (!generalGroup) return groups;
generalGroup.entries.push({
id: `myfield-option1-${field.id}`,
component: Option1Entry,
getValue: () => get(field, ['my_config', 'option1']),
setValue: (value) => {
editField(field, 'my_config', {
...get(field, ['my_config'], {}),
option1: value
});
},
field,
isEdited: isTextFieldEntryEdited
});
return groups;
};
}
}
MyFieldPropertiesProvider.$inject = ['propertiesPanel'];
function Option1Entry(props) {
const { field, id, getValue, setValue } = props;
const debounce = useService('debounceInput') ?? ((fn) => fn);
return TextFieldEntry({ debounce, element: field, getValue, id, label: 'Option 1', setValue });
}
// ============================================================
// Layer 3a: Evaluator (validation/MyFieldEvaluator.js)
// ============================================================
export class MyFieldEvaluator {
constructor(eventBus, form) {
this._eventBus = eventBus;
this._form = form;
this._errors = {};
this._eventBus.on('form.init', () => {
setTimeout(() => this.evaluateAll(), 50);
});
this._eventBus.on('changed', () => {
setTimeout(() => this.evaluateAll(), 10);
});
}
evaluateAll() {
const schema = this._form._state?.schema;
const data = this._form._state?.data || {};
if (!schema) return;
const newErrors = {};
// Your real-time validation logic here
if (JSON.stringify(this._errors) !== JSON.stringify(newErrors)) {
this._errors = newErrors;
const currentErrors = { ...(this._form._state?.errors || {}), ...newErrors };
this._form._setState({ errors: currentErrors });
}
}
getValidationErrors() {
return { ...this._errors };
}
}
MyFieldEvaluator.$inject = ['eventBus', 'form'];
// ============================================================
// Layer 3b: Validator (validation/MyFieldValidator.js)
// ============================================================
export class MyFieldValidator {
constructor(eventBus, form, myFieldEvaluator) {
this._eventBus = eventBus;
this._form = form;
this._evaluator = myFieldEvaluator;
this._eventBus.on('form.init', () => {
setTimeout(() => this._hook(), 100);
});
}
_hook() {
if (!this._form?.validate) return;
const original = this._form.validate.bind(this._form);
this._form.validate = () => {
this._evaluator.evaluateAll();
const originalErrors = original() || {};
const customErrors = this._evaluator.getValidationErrors();
const merged = { ...originalErrors };
for (const [key, err] of Object.entries(customErrors)) {
merged[key] = merged[key]
? [].concat(merged[key], err)
: err;
}
this._form._setState({ errors: merged });
return merged;
};
}
}
MyFieldValidator.$inject = ['eventBus', 'form', 'myFieldEvaluator'];
// ============================================================
// Layer 4: Module Export (MyFieldModule.js)
// ============================================================
class MyFieldRendererExtension {
constructor(formFields) {
formFields.register(myFieldType, MyFieldRenderer);
}
}
MyFieldRendererExtension.$inject = ['formFields'];
export const MyFieldRenderExtension = {
__init__: ['myFieldRenderer', 'myFieldEvaluator', 'myFieldValidator'],
myFieldRenderer: ['type', MyFieldRendererExtension],
myFieldEvaluator: ['type', MyFieldEvaluator],
myFieldValidator: ['type', MyFieldValidator]
};
export const MyFieldPropertiesPanelExtension = {
__init__: ['myFieldPropertiesProvider'],
myFieldPropertiesProvider: ['type', MyFieldPropertiesProvider]
};
The Tradeoffs
Two validation classes feels like overhead. For simple fields it is. If your field only needs submit-time validation (no real-time feedback), you can skip the Evaluator and implement everything in the Validator. But the split pays for itself the moment users ask "why didn't it tell me before I clicked submit?"
_setState is not a public API. You call this._form._setState({ errors }) to surface errors. The leading underscore signals that this is internal. It works across all current Form-JS versions but could change. The alternative — using the event bus to signal errors — doesn't surface them in the form UI without additional work.
The module split (runtime vs editor) adds files. You maintain two export objects instead of one. The benefit is that your editor never loads runtime evaluation code, and your runtime form never loads editor-only UI code. For large extensions this matters.
Separating concerns means more files. Four files for one field type feels like a lot compared to putting everything in one file. The tradeoff is that each file is independently testable, independently readable, and independently replaceable.
What Comes Next
Now you have the architecture. The next three articles go deep on specific layers.
Article 3 covers building a FEEL runtime evaluator — the kind of evaluator that can evaluate = amount > 100 using the same engine that Form-JS uses internally, with fallback to JavaScript for expressions FEEL can't handle.
Article 5 covers the properties panel in depth — specifically how to override default entries, use FeelEntry for FEEL expressions in configuration, and build dynamic panels where entries change based on other configuration values.
Article 6 covers the validation hook pattern in depth — the re-entry guard, the merge-errors pattern, and why the Evaluator/Validator split matters at scale.
This is Part 2 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 custom-components form-builder javascript devex
Top comments (0)