Part 23 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
This is the last article in the series. It's also the one to read first.
If you've arrived here without reading the others, this article will show you the complete architecture so you can identify which pieces you need and navigate directly to the relevant articles. If you've read all twenty-two articles before this one, this article assembles them into a coherent whole — showing how every piece connects to every other piece and where the system's decisions were made deliberately versus where they were made under pressure and later regretted.
What Was Built
Over twenty-two articles, the system grew to this:
5 evaluators — classes that run on every changed event and maintain runtime state:
-
BindingEvaluator— computes derived field values from FEEL expressions -
HideEvaluator— toggles field visibility based on FEEL conditions -
DisabledEvaluator— applies disabled state from expressions or static flags -
RequiredEvaluator— enforces dynamic required fields and mutates the schema -
PersistentEvaluator— marks fields for application-layer persistence -
DateTimeValidationEvaluator— validates datetime fields with scoped re-evaluation -
TicketAutoFillEvaluator— async field population from external APIs with caching
12 properties panel providers — classes that extend the form editor's configuration UI:
-
DisabledPropertiesProvider,ReadonlyPropertiesProvider,RequiredPropertiesProvider— override built-in properties with FEEL-capable versions -
FeelExpressionPropertiesProvider— adds FEEL binding entry to Form Logics -
HideIfPropertiesProvider— adds hide-if condition entry to Form Logics -
PersistentPropertiesProvider— adds persistent flag entry to Form Logics -
ShowLatestValuePropertiesProvider— adds show-latest-value entry to Form Logics -
TicketAutoFillPropertiesProvider— adds cascading API-backed configuration UI -
DropdownPropertiesProvider— replaces simple dropdown with full configuration panel -
GridFieldPropertiesProvider— adds grid-specific validation rule configuration -
DateTimePropertiesPanelExtension— configures datetime replacement field -
FileUploadPropertiesProvider— adds file validation configuration
3 custom validators — classes that extend form.validate():
-
FeelValidator— FEEL expression-based field validation at submit time -
GridFieldValidationValidator— validates grid cell contents on submit -
RequiredValidator— enforces dynamic required state at submit time
5 custom field renderers — registered for custom or replaced field types:
-
DropdownFieldRenderer— bridges five React dropdown components into Preact -
GridFieldRenderer— full custom grid with Excel import, validation, and formulas -
DateTimeFieldRenderer— replaces built-in datetime with rsuite DatePicker -
FileUploadFieldRenderer— three-stage file handling with Camunda attachment upload -
ImageViewFieldRenderer— custom image display field
Infrastructure:
-
CustomForm— subclassesForm, bootstraps all modules, firesform.rendered, initializes_pendingFilesRef -
CustomFormEditor— subclassesFormEditor, bootstraps all editor modules -
SearchableSelect— Preact searchable dropdown used throughout the properties panel -
PANEL_GROUPSandFORM_EVENTSconstants — shared registries for group IDs and event names -
excelUtils— shared import/export utility for label/value pairs
The System Architecture
Here is every component and how they connect:
The Form Lifecycle, Step by Step
Every form session follows this sequence. Here is each step with the relevant article:
Step 1: Instantiation
const form = new CustomForm({
container: document.getElementById('form-container'),
// additionalModules declared inside CustomForm constructor
});
CustomForm (Article 21) calls super() with a merged options object that includes all modules. The DI container is created. All services declared in __init__ are instantiated immediately. Evaluators and validators run their constructors, subscribing to events.
At the end of the constructor, CustomForm attaches _pendingFilesRef to the event bus instance (Article 15, 18).
What's happening invisibly: The DI container resolves all $inject arrays. RequiredEvaluator.$inject = ['eventBus', 'form'] means the DI container calls new RequiredEvaluator(eventBus, form). If $inject is missing or has a typo, the parameter is undefined — the most common error in Form-JS extension development (Article 1).
Step 2: Schema Import
await form.importSchema(schema, initialData);
Form-JS reads the schema JSON and creates the Preact component tree. For each field, it looks up the registered renderer in formFields — your replacements (Article 17) win because last-registration-wins (Article 2).
The field renderers render their Preact output. For dropdown fields, the Preact renderer renders a <div id="..."> mount point and calls createRoot() to mount the React component inside it (Article 9). The reactRoots Map tracks the root for lifecycle management.
After super.importSchema() completes, CustomForm.importSchema fires form.rendered (Article 15). This signals that the DOM is ready.
Step 3: Evaluators Initialize
Each evaluator's form.init handler fires. The evaluators:
- Set
this._initialized = true - Call
evaluateAll()after a 50ms delay -
TicketAutoFillEvaluatorcalls_buildWatchMap()to scan the schema (Article 20) -
DateTimeValidationEvaluatorcalls_buildDatetimeFieldRegistry()to build its Set (Article 22)
The initial evaluateAll() run applies the current state of all expressions against the pre-populated data. Fields with disabled: "= status = 'closed'" get the disabled attribute applied. Fields with conditional.hide: "= role != 'admin'" get display: none applied.
Step 4: External Context (Optional)
If the form needs ticket data for auto-fill, the application fires it into the form after import:
const eventBus = form.get('eventBus');
eventBus.fire('ticket.context.set', {
ticket_id: currentTicket.id,
ticket_data: currentTicket
});
TicketAutoFillEvaluator receives this via its ticket.context.set listener (Article 15, 20).
Step 5: User Interaction
The user types in a field. Form-JS updates form state. changed fires with the changed field's data.
Each evaluator's changed handler fires:
Unscoped evaluators (Article 8): DisabledEvaluator, HideEvaluator, RequiredEvaluator, PersistentEvaluator, BindingEvaluator — all check _evaluating and proceed if clear. After a 10ms debounce, evaluateAll() runs. Each checks all components, finds ones with FEEL expressions, evaluates against current form data, detects changes, updates DOM.
Scoped evaluators (Article 22): DateTimeValidationEvaluator checks whether the changed field is in _datetimeFieldKeys. If not, it returns immediately. If yes, it runs evaluation only for datetime fields with changed values.
TicketAutoFillEvaluator (Article 20): checks whether the changed field is in _watchedFields. If not, returns immediately. If yes, and the ticket selection changed, resets dependent fields, fetches from API (with cache), evaluates conditions, formats values, writes results.
Step 6: Properties Panel Changes (Editor Only)
In the form editor, when the form designer changes a property:
-
editField(field, path, value)is called — updates the schema -
propertiesPanel.updatedfires - All providers'
getGroupsfunctions run with the updated field -
getOrCreateFormLogicsGroup()is called by each provider — creates or finds the Form Logics group (Article 19) - Override providers filter out replaced entries (Article 6)
- All providers add their entries to the appropriate groups
- The panel re-renders with the updated configuration
If the change was to a FEEL expression (say, changing disabledExpression), the dual-path setValue stores it in the schema (Article 7). On the next changed event, DisabledEvaluator reads disabledExpression, evaluates it, and applies the result.
Step 7: Validation on Submit
The user clicks Submit. Form-JS calls form.validate().
Because three validators have wrapped form.validate() with the merge-errors pattern (Article 10):
form.validate() called
→ RequiredValidator's wrapper runs
→ original validate() called
→ FeelValidator's wrapper runs
→ original validate() called
→ GridFieldValidationValidator's wrapper runs
→ actual Form-JS validate() runs
← returns built-in errors
← GridFieldValidationValidator merges grid errors
← FeelValidator merges FEEL errors
← merge
← RequiredValidator merges dynamic required errors
← return merged errors
The merged errors object is written to form state via _setState({ errors }). Fields with errors get red borders and error messages below them.
If errors exist, submission stops. If no errors, the application proceeds.
Step 8: File Upload and Submission
After successful validation, the application code:
// 1. Complete the Camunda task
await camundaClient.completeTask(taskId, { variables: formData });
// 2. Upload files from _pendingFilesRef
await form.uploadPendingFiles(taskId);
uploadPendingFiles reads eventBus._pendingFilesRef.current (the Map populated by FileUpload React components via useEffect), and posts each file to /engine-rest/task/${taskId}/attachment as multipart FormData (Article 18).
Files are uploaded after task completion — not before. If task completion fails, no orphaned attachments are created.
After upload, pendingFilesRef.current.clear() prevents double-upload on re-submit.
How Every Article Connects
| Article | Component | Connects To |
|---|---|---|
| 1 — Module System | DI container, $inject
|
All modules |
| 2 — Custom Field Architecture |
GridFieldRenderer, .config, formFields.register
|
Articles 9, 17 |
| 3 — FEEL Pipeline |
evaluate(), JS fallback |
Articles 4, 8, 10 |
| 4 — FEEL Context |
prepareContext(), type coercion |
Articles 8, 13, 20 |
| 5 — Provider Contract |
getGroups, middleware pattern |
Articles 6–7, 11–12, 19 |
| 6 — Overriding Entries | Filter + replace pattern | Articles 7, 19 |
| 7 — Toggle-or-FEEL | Dual-path getValue/setValue | Articles 6, 8 |
| 8 — Five Evaluators | Event subscription, state Map, DOM | Articles 10, 15, 22 |
| 9 — React/Preact Bridge |
reactRoots Map, createRoot, generation counter |
Articles 17, 18 |
| 10 — Validation Hook |
bind(), merge-errors, _validating
|
Articles 3, 4, 8 |
| 11 — Dynamic Panels | Three conditional patterns | Articles 12, 14 |
| 12 — Cascading Config |
useEffect deps, editField, per-level state |
Articles 11, 14 |
| 13 — Conditional Options |
applyConditionalRules, form data storage |
Articles 3, 8 |
| 14 — SearchableSelect | Preact hooks, onInput, click-outside |
Articles 11, 12, 16 |
| 15 — Event Bus | Custom events, _pendingFilesRef, registry |
Articles 8, 18, 19 |
| 16 — Excel Import/Export | ExcelJS, readAsArrayBuffer, header detection |
Articles 14 |
| 17 — DateTime Replacement |
formFields.register, container-ref, subtypes |
Articles 2, 9 |
| 18 — File Handling |
localFiles, _pendingFilesRef, uploadFileAsAttachment
|
Articles 9, 15 |
| 19 — Form Logics Group |
getOrCreateFormLogicsGroup, cooperative providers |
Articles 5, 6, 15 |
| 20 — Async AutoFill | Watcher map, two-cache, context enrichment | Articles 3, 4, 8, 15 |
| 21 — CustomForm/FormEditor | Subclassing, module bootstrapping | All articles |
| 22 — Scoped Re-evaluation | Field-type gate, Set registry, change detection | Article 8 |
| 23 — Architecture | This article | All articles |
The Three Things I'd Do Differently
If I were starting this system from scratch, knowing what I know now, these are the three decisions I'd make differently.
1. Extract Shared Utilities Before Writing the Second Provider
The SearchableSelect component was written five times. The getOrCreateFormLogicsGroup function was written into six providers separately before being extracted. The FEEL evaluation pipeline was partially duplicated between evaluators. The _getAllComponents recursive function exists in nearly every evaluator.
Every duplicate was created with the same justification: "I'm not sure the API is stable yet." It's a rationalization. The real cost of not extracting is not the initial 45 minutes — it's the maintenance cost that accumulates over months. Each bug fix applied to one copy is a bug that survives in three others.
The rule I now follow: extract when you copy a second time. Not the fifth.
For a system this size, the shared utilities that should exist before you write the first provider or evaluator:
src/formjs/shared/
├── constants.ts ← PANEL_GROUPS, FORM_EVENTS
├── panelUtils.ts ← getOrCreateFormLogicsGroup, getOrCreateGroup
├── evaluatorUtils.ts ← _getAllComponents, _interpretAsBoolean, _evaluateJavaScript
├── feelPipeline.ts ← evaluate(), prepareContext(), _coerceDateValue()
├── excelUtils.ts ← importLabelValueFromExcel, exportLabelValueToExcel
└── ui/
└── SearchableSelect.tsx ← The reusable component
Writing these first — before any evaluator or provider — costs two days. It saves two weeks of cleanup later.
2. Fix the React Root Race Condition From the Start
Article 9 documents the generation counter fix for the React/Preact bridge:
// When cleanup fires 150ms after unmount,
// check that the generation hasn't changed (no remount happened)
if (current.generation === capturedGeneration) {
current.root.unmount();
}
This fix exists because I discovered the race condition three weeks after shipping the bridge. The original code — without the generation counter — caused dropdowns to disappear when Form-JS's schema update cycle unmounted and remounted the Preact renderer within 150ms.
The symptom was intermittent. It appeared when form designers updated dropdown configuration in the editor. The React root was unmounted by the stale cleanup, and the dropdown field showed a blank mount point with no error.
The root cause was knowable before shipping. The 150ms deferred cleanup was chosen to give React time to flush — but "give React time" is exactly the language that should raise a red flag. Deferred operations with fixed timeouts have race conditions. The generation counter should have been part of the original design.
The rule I now follow: any deferred operation that affects shared state needs a generation counter or equivalent cancellation mechanism.
3. Establish the Constants File Before Writing Any Provider
The Form Logics group ID 'form-logics' was a string literal in six files before I extracted it to PANEL_GROUPS.FORM_LOGICS. The event name 'required.states.changed' was a string literal in four files. The custom event names were invented ad-hoc with no consistency — some used dots (form.rendered), some used dots with subcategories (required.states.changed), one used a colon (dropdown:labelSelected).
These inconsistencies are not bugs. The system works. But they create friction:
- A new developer searching for all uses of the required states event has to search for four different strings
- A typo in any string literal causes a silent failure — no event fires, no error surfaces
- The documentation of which events exist and who fires them is scattered across twelve files
The rule I now follow: before writing the first provider or evaluator, create constants.ts. Add every group ID and every custom event name to it before it's used anywhere. This is a thirty-minute investment that makes the system legible for the lifetime of the codebase.
The complete constants file should exist as a single source of truth and include:
// The contract for custom behavior
export const PANEL_GROUPS = { ... } // All custom panel group IDs
export const FORM_EVENTS = { ... } // All custom event names
export const FIELD_TYPES = { ... } // All custom field type strings
export const SUPPORTED_FIELD_TYPES = [...] // Types that accept Form Logics entries
Four exports. Thirty minutes. Prevents a category of silent bugs for the lifetime of the project.
What the Series Covered That the Docs Don't
The official Form-JS documentation covers:
- How to render a form with
new Form({ ... }) - The basic JSON schema for field types
- A handful of examples for simple custom fields
The official documentation does not cover:
- How the DI container works or how to use
$inject - How to extend the properties panel beyond trivial examples
- How to run FEEL expressions at runtime outside the editor
- How to replace built-in field types
- How to extend
form.validate()without breaking it - How to coordinate multiple independent providers through a shared group
- How to bridge React components into Preact renderers with production-grade lifecycle management
- How to handle file uploads across framework boundaries
- How to scope re-evaluation for performance in large forms
These twenty-three articles cover the gap between "I can render a form" and "I have a production-grade extension system." That gap is where most developers spend their time and find the fewest answers.
Where to Go From Here
The system documented in this series is production-deployed and maintained. The patterns are stable. But they're not the last word — there are at least three extensions that would improve the architecture:
A formal base class for evaluators. The six shared methods (_getAllComponents, _interpretAsBoolean, _evaluateJavaScript, prepareContext, _findFieldElement, evaluateAll skeleton) should be in an AbstractEvaluator class. Each concrete evaluator would extend it and implement only _hasExpression, _getExpression, _determineState, and _applyToDOM. The current duplication is the most obvious remaining technical debt.
A Web Worker for FEEL evaluation. The FEEL pipeline runs on the main thread. For forms with many fields and complex expressions, evaluation adds measurable lag to user interactions. Moving FEEL evaluation to a Web Worker would eliminate main-thread blocking entirely. The API would be asynchronous — evaluators would need to handle Promise-based evaluation results rather than synchronous returns. This is a significant architectural change but would make the system scale to arbitrarily large forms.
A TypeScript-first rewrite of the properties panel components. The properties panel components use Preact's html tagged template literals and jsx/jsxs function calls — not JSX syntax. This makes them hard to read for developers who know React and are learning the Preact properties panel system. A rewrite using Preact's JSX transform with full TypeScript types would make the component code readable, catch type errors at compile time, and make the onInput/onChange distinction explicit in the type definitions.
None of these are urgent. The system works as documented. These are improvements to make the system more maintainable as it grows.
A Note on the Camunda DevEx Application
If you've read this series because you're considering applying to Camunda's Developer Experience team: this is the portfolio.
Not the articles as writing samples — the engineering decisions documented in the articles. The decision to use the create-if-not-exists pattern for the Form Logics group rather than a central registry. The generation counter fix for the React root race condition. The scoped re-evaluation optimization. The event bus as a cross-framework data store.
These decisions were made under real constraints — production deadline, legacy code that couldn't be rewritten, browsers that had to be supported. The articles document not just what was built but why each decision was made and what the alternatives cost.
DevEx work is ultimately about making other developers' decisions easier. The best way to demonstrate that capability is to show that you've made hard decisions yourself, documented them honestly, and built something that other developers can learn from.
That's what this series is.
This is Part 23 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.
Index of all articles in the series:
- The bpmn-io Form-JS Module System: Dependency Injection Nobody Explains
- Building Your First Custom Field in Form-JS: The Complete Four-Layer Architecture
- FEEL at Runtime in Form-JS: Building an Expression Evaluation Pipeline from Scratch
- Preparing a FEEL Context: The Type Coercion Problem Nobody Warns You About
- The Properties Panel Provider Contract: What the Official Docs Leave Out
- Overriding Default Properties Panel Entries: How to Replace What Form-JS Ships With
- The Toggle-or-FEEL Pattern: Properties That Can Be Static or Dynamic
- Five Evaluators, One Pattern: Scaling Conditional Logic Across a Form
- React Inside Preact: Mounting React Components in a Form-JS Renderer
- Hooking Into Form Validation Without Breaking It: The Merge-Errors Pattern
- Dynamic Properties Panels: Three Patterns for Conditional Entry Display
- Cascading Configuration UI: Building Dependent Selection Chains in the Properties Panel
- The Conditional Options Algorithm: Priority Mode vs Merge Mode
- Building a Searchable Select Component for the bpmn-io Properties Panel
- Using the Form-JS Event Bus as an Application Communication Layer
- Excel Import and Export in the bpmn-io Properties Panel
- Replacing a Built-In Field Type: Swapping Form-JS DateTime with rsuite DatePicker
- File Handling Across the Form Lifecycle: From Selection to Camunda Task Attachment
- The Form Logics Group: Building a Cross-Provider Panel Section
- Async AutoFill With Caching: Filling Form Fields From External APIs at Runtime
- Subclassing Form and FormEditor: Building a Custom Runtime Foundation
- Scoped Re-evaluation: Preventing Unnecessary FEEL Expression Evaluation in Large Forms
- The Full Architecture: How a Form-JS Extension System Fits Together
Tags: camunda bpmn formjs architecture series-capstone javascript devex

Top comments (0)