Part 10 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
Form-JS calls form.validate() when the user clicks submit. It collects errors from all fields, surfaces them in the UI — red borders, error messages below fields — and blocks submission if any exist.
If you have custom validation logic, you need it to run at exactly the same time and surface errors in exactly the same way. The validation system should feel unified to the user: one submit click, one pass over all fields, one set of errors.
The obvious approach is to replace form.validate() with your own version. I tried this. It breaks everything that calls the original validate — including Form-JS's own submit handler, which calls form.validate() through its own reference, not through the instance you modified. You end up with two validate functions, neither of which has the full picture.
The correct approach is to wrap the original, not replace it. This article documents the wrapping pattern, the merge-errors logic that combines results from multiple validators, the re-entry problem that emerges when you do this naively, and the Evaluator/Validator split that makes real-time feedback and submit-time validation work independently.
The Problem
Form-JS's built-in form.validate() handles the fields it knows about: required fields, min/max length, regex patterns, and other standard validations configured in the schema. It returns an errors object:
{
fieldId1: 'Field is required',
fieldId2: ['Must be at least 5 characters', 'Must match pattern'],
// keyed by field ID, values are strings or arrays of strings
}
For custom validation — FEEL expression validators, grid cell validators, dynamic required validators — you need to add to this errors object, not replace it. The form UI reads from form._state.errors to decide which fields to mark red. If you overwrite that state with only your errors, all the built-in validation disappears.
The requirements:
- Built-in validation must still run
- Your custom validation must run alongside it
- Both sets of errors must appear in the form UI
- Fields with errors from both validators must show both messages
- None of this should break when Form-JS upgrades
What I Tried First
Attempt 1: Replace form.validate()
// ❌ Attempt 1: Replacement
this._form.validate = () => {
return this._myCustomValidation();
};
Result: built-in validation stops running. Required fields that Form-JS knows about are no longer validated. The form submits with empty required fields.
Attempt 2: Call both and merge manually
// ❌ Attempt 2: Manual merge — but which reference?
this._form.validate = () => {
const builtIn = form.validate(); // ← calls itself recursively!
const custom = this._myCustomValidation();
return { ...builtIn, ...custom };
};
Result: infinite recursion. form.validate() now points to your function. Calling form.validate() inside your function calls your function again.
Attempt 3: Store a reference first
// ❌ Attempt 3: Store reference but forget to bind
const original = this._form.validate;
this._form.validate = () => {
const builtIn = original(); // ← 'this' is wrong inside original
const custom = this._myCustomValidation();
return { ...builtIn, ...custom };
};
Result: this inside original() is undefined in strict mode. Form-JS's validate implementation uses this extensively to access field registries and schema state.
Attempt 4: Store with bind — this works
// ✅ Correct approach
const originalValidate = this._form.validate?.bind(this._form);
this._form.validate = () => {
const originalErrors = originalValidate() || {};
const customErrors = this._myCustomValidation();
return merge(originalErrors, customErrors);
};
.bind(this._form) creates a new function where this is permanently set to this._form, regardless of how or where the function is called. When your wrapper calls originalValidate(), Form-JS's validate implementation runs with the correct this context.
The ?. optional chaining handles the case where form.validate doesn't exist yet when your hook runs — which can happen if you hook in before Form-JS has fully initialized.
Why bind Is Necessary
A quick explanation for why bind matters here, because it trips up developers who don't encounter this pattern often.
When you do:
const original = this._form.validate;
You're copying the function reference. The function itself has no idea what this should be — this is determined at call time, not at definition time (in non-arrow functions). When you later call original(), this inside original is determined by how you called it — in this case, as a standalone function call, so this is undefined (in strict mode) or window (in sloppy mode). Both are wrong.
When you do:
const original = this._form.validate.bind(this._form);
You create a new function that always calls the original with this set to this._form. No matter how or where you call original(), Form-JS's validate runs with the correct context.
// Without bind:
this._form.validate = function() {
const builtIn = original();
// Inside original: this === undefined → TypeError
};
// With bind:
this._form.validate = function() {
const builtIn = original(); // this._form is permanently bound
// Inside original: this === this._form → Correct
};
The Merge-Errors Pattern
The errors object from form.validate() uses field IDs as keys and error messages as values. Values can be either a string (single error) or an array of strings (multiple errors):
// Single error:
{ 'fieldId': 'This field is required' }
// Multiple errors:
{ 'fieldId': ['This field is required', 'Must match pattern'] }
When two validators both report errors for the same field, you need to merge without losing either message:
function mergeErrors(originalErrors, customErrors) {
const merged = { ...originalErrors };
for (const [fieldId, customError] of Object.entries(customErrors)) {
if (!merged[fieldId]) {
// ✅ Only in custom — add directly
merged[fieldId] = customError;
} else if (Array.isArray(merged[fieldId])) {
// ✅ Original already has multiple errors — append
if (Array.isArray(customError)) {
merged[fieldId] = [...merged[fieldId], ...customError];
} else {
merged[fieldId] = [...merged[fieldId], customError];
}
} else {
// ✅ Original has a single error string — create array
if (Array.isArray(customError)) {
merged[fieldId] = [merged[fieldId], ...customError];
} else {
merged[fieldId] = [merged[fieldId], customError];
}
}
}
return merged;
}
The spread { ...originalErrors } is important — you're creating a new object, not mutating the original. Form-JS may hold a reference to the original errors object and mutating it could cause subtle issues.
How _setState({ errors }) Surfaces Errors in the UI
After form.validate() returns the merged errors object, you need to push it into the form state so the UI reflects it:
this._form._setState({ errors: merged });
This is the call that makes red borders appear, error messages show below fields, and submit buttons disable. Without this call, form.validate() returns errors but the UI shows nothing.
_setState is form-js's internal state updater — the underscore prefix signals it's not a public API. It's the same method used by the evaluators in Article 8. It works reliably across current Form-JS versions but could change.
The state shape that Form-JS expects:
{
errors: {
'fjs-form-{formId}-{fieldId}': 'Error message',
// OR
'fjs-form-{formId}-{fieldId}': ['Error 1', 'Error 2'],
}
}
Notice that Form-JS errors are keyed by the full field element ID (including form ID prefix) in some cases, and by the field schema ID in others. The behavior depends on which part of Form-JS is reading the errors. In practice, keying by component.id (the schema ID, not the prefixed DOM ID) works for surfacing errors through _setState, while the DOM attributes approach from _applyToDOM handles the visual element targeting.
The Re-Entry Problem
Here is the infinite loop that emerges from naive validator hooking:
form.validate() called by submit handler
→ originalValidate() runs
→ customValidation() runs
→ _setState({ errors: merged }) called
→ Form-JS detects state change
→ 'changed' event fires
→ RequiredEvaluator.evaluateAll() runs
→ form.validate() called again (by RequiredValidator)
→ originalValidate() runs
→ customValidation() runs
→ _setState({ errors }) called again
→ 'changed' fires again
→ infinite loop
The fix is a _validating flag on each validator class:
export class MyValidator {
constructor(eventBus, form) {
this._validating = false;
this._eventBus.on('form.init', () => {
setTimeout(() => this._hookIntoValidation(), 100);
});
}
_hookIntoValidation() {
const originalValidate = this._form.validate?.bind(this._form);
this._form.validate = () => {
// ✅ Re-entry guard — if already validating, return last known errors
if (this._validating) {
return this._lastErrors;
}
this._validating = true;
try {
const originalErrors = originalValidate() || {};
const customErrors = this._validateCustom();
const merged = mergeErrors(originalErrors, customErrors);
this._lastErrors = merged;
return merged;
} finally {
// ✅ Always release — even if validation throws
this._validating = false;
}
};
}
}
When the re-entry occurs, the second call to form.validate() hits the guard and returns this._lastErrors — the errors from the previous validation run — without running validation again. This breaks the loop.
this._lastErrors is initialized to {} and updated every time validation completes successfully. If validation hasn't run yet and re-entry occurs, it returns {} — no errors — which is the correct safe default.
The Evaluator/Validator Split
For the grid field, I have two separate classes doing two separate jobs:
GridFieldValidationEvaluator — runs continuously, provides real-time cell-level feedback
GridFieldValidationValidator — hooks into form.validate(), blocks submit on errors
These are separate for a reason. Real-time validation and submit-time validation have different requirements:
| Concern | Real-time (Evaluator) | Submit-time (Validator) |
|---|---|---|
| When it runs | On every changed event |
Only when form.validate() is called |
| What it validates | Individual cells as they change | The complete grid, all cells |
| How errors surface | Via _setState({ errors }) directly |
Via the merged errors object from form.validate()
|
| Performance requirement | Fast — runs on every keystroke | Can be thorough — runs once on submit |
| Purpose | Visual feedback while editing | Enforcement before submission |
If you only had submit-time validation, users would fill in an entire grid incorrectly and only find out when they clicked Submit. If you only had real-time validation, the errors might not be picked up by form.validate() and the form would submit with invalid data.
You need both. Hence two classes.
All Three Implementations
Implementation 1: FeelValidator
Validates fields that have custom FEEL validation rules in component.validate.customValidations:
// FeelValidator.js
export class FeelValidator {
constructor(eventBus, form, formFieldRegistry) {
this._eventBus = eventBus;
this._form = form;
this._formFieldRegistry = formFieldRegistry;
this._feelEngine = { evaluate };
this._validating = false;
this._lastErrors = {};
this._eventBus.on('form.init', () => {
setTimeout(() => this._hookIntoValidation(), 100);
});
// Surface errors on submit
this._eventBus.on('submit', () => {
if (Object.keys(this._lastErrors).length > 0) {
this._applyErrorsToForm();
}
});
}
_hookIntoValidation() {
const originalValidate = this._form.validate?.bind(this._form);
this._form.validate = () => {
// ✅ Re-entry guard
if (this._validating) {
return this._lastErrors;
}
this._validating = true;
try {
// 1) Run Form-JS built-in validation
const originalErrors = originalValidate() || {};
// 2) Run FEEL custom validation
const customErrors = this._validateFeel();
// 3) Merge
const merged = { ...originalErrors };
for (const [key, err] of Object.entries(customErrors)) {
if (!merged[key]) {
merged[key] = err;
} else if (Array.isArray(merged[key])) {
merged[key].push(err);
} else {
merged[key] = [merged[key], err];
}
}
this._lastErrors = merged;
return merged;
} finally {
this._validating = false;
}
};
}
_validateFeel() {
const errors = {};
const schema = this._form._state?.schema;
const data = this._form._state?.data || {};
if (!schema) return errors;
const allComponents = this._getAllComponents(schema.components || []);
for (const component of allComponents) {
if (!component?.key) continue;
// Check for custom FEEL validation rules
const validationRules = component.validate?.customValidations || [];
if (!Array.isArray(validationRules) || !validationRules.length) continue;
for (const rule of validationRules) {
if (!rule.expression) continue;
try {
const isValid = this._evaluateValidation(rule.expression, data);
if (!isValid) {
const errorMessage = rule.message ||
`Validation failed for ${component.label || component.key}`;
if (!errors[component.key]) {
errors[component.key] = errorMessage;
} else if (Array.isArray(errors[component.key])) {
errors[component.key].push(errorMessage);
} else {
errors[component.key] = [errors[component.key], errorMessage];
}
}
} catch (err) {
console.error(`[FeelValidator] Error evaluating ${component.key}:`, err.message);
}
}
}
return errors;
}
_evaluateValidation(expression, data) {
if (!expression || typeof expression !== 'string') return true;
try {
let cleanExpr = expression.trim();
if (cleanExpr.startsWith('=')) cleanExpr = cleanExpr.substring(1).trim();
const context = this._prepareContext(data);
try {
const result = this._feelEngine.evaluate('expression', cleanExpr, context);
if (result !== null && result !== undefined) {
return this._interpretAsBoolean(result);
}
} catch (feelErr) {
// Fall through to JavaScript
}
return this._evaluateJavaScript(cleanExpr, context);
} catch (err) {
console.error(`Validation eval error for "${expression}":`, err.message);
return true; // ✅ On error: consider valid — don't block submission
}
}
_applyErrorsToForm() {
if (this._validating) return;
const errors = this._form.validate() || {};
const currentErrors = this._form._state?.errors || {};
if (JSON.stringify(currentErrors) !== JSON.stringify(errors)) {
this._form._setState({ errors });
}
}
_getAllComponents(components = []) {
const all = [];
for (const c of components) {
all.push(c);
if (c.type === 'group' && Array.isArray(c.components)) {
all.push(...this._getAllComponents(c.components));
}
}
return all;
}
_prepareContext(data) {
const context = {};
Object.keys(data).forEach(key => {
const value = data[key];
if (value === null || value === undefined) context[key] = null;
else if (typeof value === 'boolean') context[key] = value;
else if (typeof value === 'number') context[key] = value;
else if (typeof value === 'string') {
if (value === 'true') { context[key] = true; return; }
if (value === 'false') { context[key] = false; return; }
const num = Number(value);
context[key] = (!isNaN(num) && value !== '') ? num : value;
} else context[key] = value;
});
context.now = () => new Date();
context.today = () => { const d = new Date(); d.setHours(0,0,0,0); return d; };
return context;
}
_evaluateJavaScript(expression, context) {
try {
const keys = Object.keys(context);
const values = Object.values(context);
const fn = new Function(
...keys,
`'use strict'; try { return (${expression}); } catch(e) { return true; }`
);
return fn(...values);
} catch (err) {
return true;
}
}
_interpretAsBoolean(value) {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const n = value.trim().toLowerCase();
if (n === 'true') return true;
if (n === 'false') return false;
return n.length > 0;
}
if (typeof value === 'number') return value !== 0;
return !!value;
}
}
FeelValidator.$inject = ['eventBus', 'form', 'formFieldRegistry'];
Implementation 2: GridFieldValidationValidator
Hooks into submit-time validation for the grid field. Works with GridFieldValidationEvaluator which handles real-time feedback:
// GridFieldValidationValidator.js
export class GridFieldValidationValidator {
constructor(eventBus, form, gridFieldValidationEvaluator) {
this._eventBus = eventBus;
this._form = form;
// ✅ Injected by name — the evaluator runs continuously
// The validator calls it at submit time to get the latest errors
this._evaluator = gridFieldValidationEvaluator;
this._eventBus.on('form.init', () => {
setTimeout(() => this._hookIntoValidation(), 100);
});
}
_hookIntoValidation() {
if (!this._form?.validate) {
console.warn('[GridFieldValidationValidator] form.validate not available');
return;
}
const originalValidate = this._form.validate.bind(this._form);
this._form.validate = () => {
// ✅ Force the evaluator to run NOW with current data
// The evaluator runs on 'changed' events but the user
// may have submitted without triggering a change
this._evaluator.evaluateAll();
// 1) Original validation
const originalErrors = originalValidate() || {};
// 2) Grid validation errors (from the evaluator)
const gridErrors = this._evaluator.getValidationErrors();
// 3) Merge
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 in form UI
this._form._setState({ errors: merged });
return merged;
};
}
}
GridFieldValidationValidator.$inject = [
'eventBus',
'form',
'gridFieldValidationEvaluator' // ✅ Injected by service name
];
Notice that GridFieldValidationValidator does not have a _validating flag. The grid validator calls this._evaluator.evaluateAll() which is the evaluator's method, not form.validate() again. There's no re-entry risk because evaluateAll doesn't call form.validate().
The evaluator's getValidationErrors() method returns the errors it computed during its last evaluateAll() run:
// In GridFieldValidationEvaluator:
getValidationErrors() {
return { ...this._validationErrors };
}
This decouples the evaluator (continuous real-time) from the validator (submit-time). The validator doesn't re-implement validation logic — it calls evaluateAll() to ensure the evaluator is current, then reads its results.
Implementation 3: RequiredValidator
The most complex of the three because RequiredEvaluator exposes dynamic required states that need to integrate with the built-in required field validation:
// RequiredValidator.js
export class RequiredValidator {
constructor(eventBus, form, requiredEvaluator, formFieldRegistry) {
this._eventBus = eventBus;
this._form = form;
this._requiredEvaluator = requiredEvaluator;
this._formFieldRegistry = formFieldRegistry;
this._eventBus.on('form.init', () => {
setTimeout(() => this._hookIntoValidation(), 100);
});
// ✅ Re-validate when required states change
// RequiredEvaluator fires this event when a field's required state changes
this._eventBus.on('required.states.changed', () => this._applyErrorsToForm());
// ✅ Re-validate on submit
this._eventBus.on('submit', () => this._applyErrorsToForm());
}
_hookIntoValidation() {
const originalValidate = this._form.validate?.bind(this._form);
this._form.validate = () => {
// 1) Original validation
const originalErrors = originalValidate() || {};
// 2) Dynamic required validation
const customErrors = this._validateRequired();
// 3) Merge
const merged = { ...originalErrors };
for (const [key, err] of Object.entries(customErrors)) {
if (!merged[key]) {
merged[key] = err;
} else if (Array.isArray(merged[key])) {
merged[key].push(err);
} else {
merged[key] = [merged[key], err];
}
}
return merged;
};
}
_applyErrorsToForm() {
const errors = this._form.validate() || {};
// ✅ Only update state if errors actually changed
const currentErrors = this._form._state?.errors || {};
if (JSON.stringify(currentErrors) !== JSON.stringify(errors)) {
this._form._setState({ errors });
}
}
_validateRequired() {
const errors = {};
const schema = this._form._state?.schema;
const data = this._form._state?.data || {};
if (!schema) return errors;
const allComponents = this._getAllComponents(schema.components || []);
const fields = this._formFieldRegistry?.getAll?.() || [];
for (const component of allComponents) {
if (!component?.key) continue;
// ✅ Check three sources of required state:
// 1. Static validate.required in schema
const staticRequired = component.required === true ||
component.validate?.required === true;
// 2. Dynamic required from RequiredEvaluator (FEEL expressions)
const dynamicRequired = this._requiredEvaluator.isFieldRequired(component.key);
// 3. Runtime flags on field instances (some renderers set these)
const field = fields.find(f => f.config?.key === component.key);
const fieldDynamicRequired = field?.config?._dynamicRequired || false;
const isRequired = staticRequired || dynamicRequired || fieldDynamicRequired;
if (isRequired) {
const value = data[component.key];
if (this._isValueEmpty(value)) {
errors[component.key] = this._getErrorMessage(component);
}
}
}
return errors;
}
_isValueEmpty(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
if (typeof value === 'object' && Object.keys(value).length === 0) return true;
return false;
}
_getErrorMessage(component) {
// Use custom message if configured
if (typeof component.validate?.required === 'string') {
return component.validate.required;
}
const label = component.label || component.key;
return `${label} is required`;
}
_getAllComponents(components = []) {
const all = [];
for (const c of components) {
all.push(c);
if (c.type === 'group' && Array.isArray(c.components)) {
all.push(...this._getAllComponents(c.components));
}
}
return all;
}
}
RequiredValidator.$inject = [
'eventBus',
'form',
'requiredEvaluator', // ✅ From RequiredEvaluator — has dynamic required states
'formFieldRegistry'
];
RequiredValidator listens to 'required.states.changed' — fired by RequiredEvaluator when a field's required state changes at runtime. This means required errors surface as the user fills in the form, not just on submit. When category changes to "other" and subcategoryField becomes dynamically required, the required indicator appears immediately.
The Pattern They All Share
All three implementations follow the same five-step structure:
// Step 1: Store re-entry guard and last errors
this._validating = false;
this._lastErrors = {};
// Step 2: Hook on form.init (after form is ready)
this._eventBus.on('form.init', () => {
setTimeout(() => this._hookIntoValidation(), 100);
});
// Step 3: Wrap with bind
_hookIntoValidation() {
const originalValidate = this._form.validate?.bind(this._form);
this._form.validate = () => {
if (this._validating) return this._lastErrors; // Re-entry guard
this._validating = true;
try {
const originalErrors = originalValidate() || {};
const customErrors = this._validateCustom();
const merged = mergeErrors(originalErrors, customErrors);
this._lastErrors = merged;
return merged;
} finally {
this._validating = false; // Always release
}
};
}
// Step 4: Custom validation returns errors object
_validateCustom() {
const errors = {};
// ... your validation logic ...
return errors;
}
// Step 5: Surface in form UI when needed
_applyErrorsToForm() {
if (this._validating) return;
const errors = this._form.validate() || {};
const currentErrors = this._form._state?.errors || {};
if (JSON.stringify(currentErrors) !== JSON.stringify(errors)) {
this._form._setState({ errors });
}
}
The re-entry guard differs slightly between implementations. FeelValidator needs it because it's called both on submit and through the required.states.changed event chain. GridFieldValidationValidator doesn't need it because it doesn't call form.validate() recursively. RequiredValidator uses a JSON comparison in _applyErrorsToForm to prevent unnecessary state updates rather than a _validating flag.
One Critical Detail: The 100ms Delay on form.init
Every validator hooks into validation in a form.init handler with a 100ms delay:
this._eventBus.on('form.init', () => {
setTimeout(() => this._hookIntoValidation(), 100);
});
Without the delay, this._form.validate may not exist yet when form.init fires. Form-JS initializes its internal services in a specific order, and validate is set up after some other initialization steps. Hooking in immediately on form.init sometimes results in this._form.validate being undefined when you try to bind it.
The 100ms delay gives Form-JS's initialization time to complete. This is fragile — it's a timing assumption, not a contract. A more robust approach would be to check for the method's existence with a retry:
_hookIntoValidation() {
if (!this._form?.validate) {
// Not ready yet — try again
setTimeout(() => this._hookIntoValidation(), 50);
return;
}
const originalValidate = this._form.validate.bind(this._form);
// ... rest of hook
}
In practice, 100ms has been reliable across all Form-JS versions I've used. The retry approach is safer but adds complexity.
The Complete Module Exports
// FeelValidatorModule.js
import { FeelValidator } from './FeelValidator';
export default {
__init__: ['feelValidator'],
feelValidator: ['type', FeelValidator]
};
// GridFieldValidationModule.js
import { GridFieldValidationEvaluator } from './GridFieldValidationEvaluator';
import { GridFieldValidationValidator } from './GridFieldValidationValidator';
export default {
__init__: ['gridFieldValidationEvaluator', 'gridFieldValidationValidator'],
// ✅ Both must be in __init__ — they subscribe to events in constructors
gridFieldValidationEvaluator: ['type', GridFieldValidationEvaluator],
gridFieldValidationValidator: ['type', GridFieldValidationValidator]
// ✅ The validator injects the evaluator by name — DI resolves the dependency
};
// RequiredModule.js
import { RequiredEvaluator } from './RequiredEvaluator';
import { RequiredValidator } from './RequiredValidator';
export default {
__init__: ['requiredEvaluator', 'requiredValidator'],
requiredEvaluator: ['type', RequiredEvaluator],
requiredValidator: ['type', RequiredValidator]
// ✅ Order matters: evaluator must be registered before validator
// (validator injects 'requiredEvaluator' by name)
};
The registration order in the module object matters when there's a dependency between services. requiredEvaluator must be registered before requiredValidator because the DI container resolves requiredEvaluator as a dependency when creating RequiredValidator. If requiredValidator is listed first and tries to inject requiredEvaluator before it's registered, the injection fails.
The Tradeoffs
Multiple validators wrapping the same form.validate() create a chain: Validator A wraps the original, Validator B wraps Validator A's wrapper, Validator C wraps Validator B's wrapper. Each call to form.validate() runs all three in sequence. The error from each has the re-entry guard for its own level, but the overall call chain gets deeper with each additional validator.
_setState({ errors }) is the only way to surface errors in the form UI but it's an internal API. A public form.setErrors() method would be safer. If Form-JS changes how errors are stored in state, all three validators need updates.
The 100ms initialization delay is a timing assumption, not a contract. If Form-JS's initialization becomes faster or slower in a future version, 100ms might be too long (UI delay) or too short (hook fails). The retry approach is more robust but more complex.
JSON.stringify for change detection in _applyErrorsToForm has a performance cost on large error objects and can give false negatives if object key order changes. A proper deep equality function would be more correct, though in practice the JSON approach works for the small error objects form validation produces.
What Comes Next
Validation hooks run at submit time. But the user experience is better when errors appear as the user fills in the form — not only after they click Submit. The evaluators from Article 8 handle real-time state changes. Connecting those evaluators to the validation system through custom events is the next layer.
Article 11 covers the event bus as an application communication layer — how 'required.states.changed', 'disabled.states.changed', and 'hidden.states.changed' events connect evaluators, validators, and application code without direct coupling, and why the event bus is the right transport for this in a Form-JS extension system.
This is Part 10 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 validation feel form-builder javascript devex
Top comments (0)