DEV Community

Sam Abaasi
Sam Abaasi

Posted on

Hooking Into Form Validation Without Breaking It: The Merge-Errors Pattern

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Built-in validation must still run
  2. Your custom validation must run alongside it
  3. Both sets of errors must appear in the form UI
  4. Fields with errors from both validators must show both messages
  5. 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();
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

.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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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'] }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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'],
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
      }
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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'];
Enter fullscreen mode Exit fullscreen mode

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
];
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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'
];
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
};
Enter fullscreen mode Exit fullscreen mode

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)