DEV Community

Cover image for React Inside Preact: Mounting React Components in a Form-JS Renderer
Sam Abaasi
Sam Abaasi

Posted on

React Inside Preact: Mounting React Components in a Form-JS Renderer

Part 9 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"


Form-JS renders in Preact. If you're extending it with custom field types, your renderers are Preact components. That's the expected path and it works well.

Unless you already have React components.

I had five dropdown components — FormStaticSelectDropdown, FormApiSingleSelectDropdown, FormApiMultiSelectDropdown, FormMultiMegaDropdown, FormSingleMegaDropdown — built in React with complex async state management, paginated API fetching, virtual scrolling, and keyboard navigation. Each one had taken days to build and test. They worked. Rewriting them in Preact to satisfy Form-JS's renderer expectations would mean throwing away weeks of working code.

So I built a bridge instead. A Preact component that renders an empty div, then mounts a React root into that div. React renders inside Preact. Both frameworks coexist in the same DOM tree.

This article documents exactly how that bridge works, the specific problems I hit while building it, and the complete template you can use for your own React-in-Preact renderers.


The Problem

Form-JS field renderers are registered as functions or classes that return Preact-compatible JSX or html template literals. The framework expects Preact. If you hand it a React component directly, it either crashes or renders nothing — React's virtual DOM is not compatible with Preact's.

The naive approach is to just use Preact. Most Form-JS extension examples use Preact's html tagged template literals:

// The expected Form-JS renderer approach
export function MyFieldRenderer(props) {
  return html`<div class="my-field">
    <!-- Preact html template -->
  </div>`;
}
Enter fullscreen mode Exit fullscreen mode

For simple fields this is fine. For a dropdown with 200+ options, async API fetching, virtual scrolling, multi-select with tag display, hierarchical cascading, and keyboard navigation — rewriting in Preact is a week of work for each component.

The bridge approach: keep your React components untouched, render a Preact wrapper that provides a mount point, and use React's createRoot to mount your React component into that mount point.


What I Tried First

My first attempt was to use React's render function (the legacy React 17 API) directly inside the Preact renderer:

// ❌ Attempt 1: Legacy React render
import ReactDOM from 'react-dom';
import { FormStaticSelectDropdown } from './FormStaticSelectDropdown';

export function DropdownFieldRenderer(props) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (containerRef.current) {
      ReactDOM.render(
        <FormStaticSelectDropdown {...componentProps} />,
        containerRef.current
      );
    }

    return () => {
      if (containerRef.current) {
        ReactDOM.unmountComponentAtNode(containerRef.current);
      }
    };
  }, []);

  return html`<div ref=${containerRef}></div>`;
}
Enter fullscreen mode Exit fullscreen mode

This mostly worked in development. In production builds with React 18, ReactDOM.render is deprecated and produces console warnings. More importantly, the cleanup was wrong — unmountComponentAtNode is synchronous and React 18's concurrent rendering doesn't always flush synchronously. Some cleanup calls caused React warnings about unmounting components that were still rendering.

My second attempt was createRoot from React 18:

// ❌ Attempt 2: createRoot without lifecycle management
import { createRoot } from 'react-dom/client';

export function DropdownFieldRenderer(props) {
  const containerRef = useRef(null);
  const rootRef = useRef(null);

  useEffect(() => {
    if (containerRef.current && !rootRef.current) {
      rootRef.current = createRoot(containerRef.current);
      rootRef.current.render(<FormStaticSelectDropdown {...componentProps} />);
    }

    return () => {
      rootRef.current?.unmount();
      rootRef.current = null;
    };
  }, []);

  return html`<div ref=${containerRef}></div>`;
}
Enter fullscreen mode Exit fullscreen mode

This worked for one instance of the dropdown. It broke when two dropdowns existed on the same form simultaneously. The rootRef was scoped to the component instance, so each dropdown had its own root — which is correct. But when Form-JS re-rendered a field (due to a schema change or data update), it could unmount and remount the Preact component, creating a new root that tried to mount into an already-rooted container. React threw: "You are calling ReactDOM.createRoot() on a container that was already passed to createRoot()."

The root cause: containerRef.current could be the same DOM element across remounts if Form-JS reused the DOM node. Creating a second root on the same container is an error.

The solution required proper lifecycle management — tracking which containers have active roots, preventing duplicate roots, and handling the unmount timing correctly.


The Solution: The reactRoots Map

The core of the bridge is a module-level Map that tracks all active React roots by container ID:

// Module-level — shared across all instances of all field renderers
const reactRoots = new Map();
// Key: containerId (string) — e.g., "fjs-form-abc123-field456-dropdown-container-instance-1"
// Value: { root, fieldId, instanceId, mounted, container, generation }
Enter fullscreen mode Exit fullscreen mode

Module-level means this Map persists for the lifetime of the page. Every dropdown field renderer instance registers its root here on mount and cleans it up on unmount. Before creating a new root, you check the Map — if a root already exists for this container, you reuse it rather than creating a duplicate.

Why Container ID, Not Element Reference

The container ID is a string built from the form ID, field ID, and a unique instance counter:

const instanceId = useRef(componentInstanceId()).current;
// componentInstanceId() returns 'instance-1', 'instance-2', etc.
// Increments globally — each renderer gets a unique ID

const fieldId = prefixId(id, formId);
// e.g., "fjs-form-abc123-field456"

const containerId = `${fieldId}-dropdown-container-${instanceId}`;
// e.g., "fjs-form-abc123-field456-dropdown-container-instance-1"
Enter fullscreen mode Exit fullscreen mode

Using a string ID rather than an element reference as the Map key means the Map works correctly even if the DOM element is replaced between renders. The ID is stable; the element reference might not be.


The useLayoutEffect vs useEffect Split

The bridge uses two different hooks for two different purposes, and the distinction matters.

useLayoutEffect — For Mounting

useLayoutEffect(() => {
  const container = document.getElementById(containerId);
  if (!container) {
    console.warn(`Container ${containerId} not found during mount`);
    return;
  }

  isMountedRef.current = true;

  const { Component, props: componentProps } = selectComponentAndProps(
    fieldRef.current,
    value,
    onChange,
    disabled,
    readonly,
    errors,
    formData,
    fieldId
  );

  let rootData = reactRoots.get(containerId);

  try {
    if (!rootData) {
      // ✅ No existing root — create one
      const root = createRoot(container);
      root.render(React.createElement(Component, componentProps));
      reactRoots.set(containerId, {
        root,
        fieldId: id,
        instanceId,
        mounted: true,
        container,
        generation: 1  // ✅ Generation counter — covered below
      });
    } else {
      // ✅ Root exists — re-render into it
      rootData.mounted = true;
      rootData.root.render(React.createElement(Component, componentProps));
    }
  } catch (error) {
    console.error(`Error rendering React component for ${containerId}:`, error);
  }
}, [containerId, id, field, value, onChange, disabled, readonly, errors, formData]);
Enter fullscreen mode Exit fullscreen mode

useLayoutEffect runs synchronously after the DOM is updated but before the browser paints. This ensures the React component is mounted and rendered before the user sees the placeholder div. If you use useEffect for mounting, there's a brief flash where the empty div is visible before React fills it.

useEffect — For Cleanup

useEffect(() => {
  isMountedRef.current = true;

  return () => {
    isMountedRef.current = false;
    const rootData = reactRoots.get(containerId);

    if (rootData) {
      rootData.mounted = false;
      const capturedGeneration = rootData.generation; // ✅ Capture current generation

      setTimeout(() => {
        const currentRootData = reactRoots.get(containerId);

        // ✅ Only unmount if:
        // 1. The root data still exists for this container
        // 2. It's still marked as unmounted
        // 3. The generation hasn't changed (no remount happened)
        if (currentRootData &&
            !currentRootData.mounted &&
            currentRootData.generation === capturedGeneration) {
          try {
            const containerStillExists = document.getElementById(containerId);
            if (containerStillExists && containerStillExists.parentNode) {
              currentRootData.root.unmount();
            }
            reactRoots.delete(containerId);
          } catch (error) {
            console.warn(`Error during cleanup for ${containerId}:`, error);
            reactRoots.delete(containerId);
          }
        }
      }, 150);
    }
  };
}, [containerId]);
Enter fullscreen mode Exit fullscreen mode

Cleanup runs in useEffect (not useLayoutEffect) because cleanup should happen after paint. More importantly, cleanup is deferred — the actual unmount happens 150ms after the component unmounts.


The Race Condition and the Generation Counter Fix

The deferred cleanup introduces a race condition. Here is the exact scenario:

t=0ms:   Preact component mounts
         → useLayoutEffect runs, creates React root, generation = 1
         → reactRoots.set(containerId, { mounted: true, generation: 1 })

t=50ms:  Form-JS re-renders (schema change, data update, etc.)
         Preact component unmounts
         → useEffect cleanup runs
         → rootData.mounted = false
         → capturedGeneration = 1
         → setTimeout(cleanup, 150ms) scheduled

t=60ms:  Preact component remounts (same containerId — same form field)
         → useLayoutEffect runs again
         → rootData already exists in Map
         → rootData.mounted = true
         → rootData.generation stays 1  ← BUG: generation not incremented!
         → root.render() called (re-render, not new mount)

t=200ms: The setTimeout from t=50ms fires
         → currentRootData.generation === capturedGeneration (both 1)
         → Cleanup condition passes ← BUG: unmounts the live root!
         → React root from t=60ms is unmounted
         → Dropdown disappears
Enter fullscreen mode Exit fullscreen mode

Without the generation counter, remounting within the 150ms window causes the cleanup to unmount the new root. The generation counter fixes this:

// On mount: if root already exists, increment generation
if (!rootData) {
  reactRoots.set(containerId, {
    root,
    fieldId: id,
    instanceId,
    mounted: true,
    container,
    generation: 1
  });
} else {
  rootData.mounted = true;
  rootData.generation += 1; // ✅ Increment on remount
  rootData.root.render(React.createElement(Component, componentProps));
}
Enter fullscreen mode Exit fullscreen mode
// In cleanup timeout:
const capturedGeneration = rootData.generation; // Capture at unmount time

setTimeout(() => {
  const currentRootData = reactRoots.get(containerId);
  if (currentRootData &&
      !currentRootData.mounted &&
      currentRootData.generation === capturedGeneration) { // ✅ Generation check
    currentRootData.root.unmount();
    reactRoots.delete(containerId);
  }
  // If generation changed (remount happened), this condition fails
  // and we correctly skip the unmount
}, 150);
Enter fullscreen mode Exit fullscreen mode

With the generation counter, the scenario plays out correctly:

t=50ms:  Component unmounts
         capturedGeneration = 1
         setTimeout scheduled

t=60ms:  Component remounts
         rootData.generation = 2  ← incremented

t=200ms: setTimeout fires
         currentRootData.generation === 2
         capturedGeneration === 1
         2 !== 1 → condition fails → unmount skipped ✅
         Live root survives
Enter fullscreen mode Exit fullscreen mode

The selectComponentAndProps Dispatch Function

The dropdown field renderer needs to route to five different React components depending on configuration. Rather than writing conditional logic inline in the renderer, all of it is encapsulated in a single dispatch function:

function selectComponentAndProps(field, value, onChange, disabled, readonly, errors, formData, fieldId) {
  const config = field.dropdown_config || {};

  // ========== 1. STATIC/MANUAL DROPDOWNS ==========
  if (config.data_source === 'manual' && Array.isArray(config.static_values)) {
    return {
      Component: FormStaticSelectDropdown,
      props: {
        formData,
        options: config.static_values || [],
        filterOptions: config.conditional_rules,
        priorityMode: config.priority_mode !== false,
        mode: config.is_multi ? 'multi' : 'single',
        searchable: config.is_searchable !== false,
        disabled,
        readOnly: readonly,
        defaults: config.is_multi ? parseMultiSelectValue(value) : value,
        onChangeValue: onChange,
        inputId: fieldId || `dropdown-${field.key}`,
      }
    };
  }

  // ========== 2. RESOLVE API CONFIG ==========
  const apiConfig = resolveApiConfig(config, formData);

  if (!apiConfig) {
    // Return a message instead of a component
    if (config.data_source === 'predefined_apis' && !config.predefined_api_details) {
      return {
        Component: null,
        message: 'Please select a Predefined API from the properties panel on the right →'
      };
    }
    return { Component: null, props: {} };
  }

  const apiInfo = buildApiInfo(apiConfig, field);
  const filterOptions = buildFilterOptions(config, formData);
  const hasFilterOptions = filterOptions.length > 0;

  // ========== 3. MEGA DROPDOWN VARIANTS ==========
  if (config.is_mega_dropdown) {
    if (config.is_multi) {
      return {
        Component: FormMultiMegaDropdown,
        props: {
          field, formData, apiInfo, filterOptions, hasFilterOptions,
          inputId: fieldId || `dropdown-${field.key}`,
          title: field.label || 'Select Options',
          disabled, readOnly: readonly,
          defaultValue: parseMultiSelectValue(value),
          onChangeValue: onChange,
          searchable: config.is_searchable !== false,
        }
      };
    } else {
      return {
        Component: FormSingleMegaDropdown,
        props: {
          field, formData, apiInfo, filterOptions, hasFilterOptions,
          inputId: fieldId || `dropdown-${field.key}`,
          title: field.label || 'Select Option',
          disabled, readOnly: readonly,
          defaultValue: value,
          onChangeValue: onChange,
          searchable: config.is_searchable !== false,
        }
      };
    }
  }

  // ========== 4. REGULAR API DROPDOWNS ==========
  if (config.is_multi) {
    return {
      Component: FormApiMultiSelectDropdown,
      props: {
        field, formData, apiInfo,
        value: parseMultiSelectValue(value),
        onChange,
        filterOptions, hasFilterOptions,
        disabled, readOnly: readonly,
        searchable: config.is_searchable !== false,
      }
    };
  } else {
    return {
      Component: FormApiSingleSelectDropdown,
      props: {
        field, formData, apiInfo,
        value: value || null,
        onChange,
        filterOptions, hasFilterOptions,
        disabled, readOnly: readonly,
        placeholder: config.placeholder || 'Select Option',
        searchable: config.is_searchable !== false,
      }
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The dispatch function returns { Component, props } or { Component: null, message }. The renderer uses the result:

const { Component, props: componentProps, message } = selectComponentAndProps(...);

if (!Component) {
  // Show configuration message instead of a component
  container.innerHTML = `<div style="...">${message || '⚠️ Unsupported configuration'}</div>`;
  return;
}

root.render(React.createElement(Component, componentProps));
Enter fullscreen mode Exit fullscreen mode

This keeps the renderer simple — it doesn't need to know about the five different dropdown types. All the routing logic lives in selectComponentAndProps where it can be tested independently.

The Multi-Select Value Parser

Before props are passed to components, multi-select values go through a parser that handles three historical data formats. This is a legacy compatibility layer — your new implementations use JSON arrays, but older form submissions may have used CSV strings or base64-encoded byte strings:

function parseMultiSelectValue(value) {
  if (value === null || value === undefined || value === '') return [];
  if (Array.isArray(value)) return value;

  if (typeof value === 'string') {
    // Format 1: JSON array (current format)
    try {
      const parsed = JSON.parse(value);
      if (Array.isArray(parsed)) return parsed;
    } catch {
      // Not JSON
    }

    // Format 2: Base64-encoded bytes (legacy)
    try {
      const jsonString = decodeURIComponent(escape(atob(value)));
      const parsed = JSON.parse(jsonString);
      if (Array.isArray(parsed) && parsed.length > 0) {
        console.warn('⚠️ Legacy Bytes format detected');
        return parsed;
      }
    } catch {
      // Not bytes
    }

    // Format 3: CSV string (legacy)
    if (value.includes(',')) {
      console.warn('⚠️ Legacy CSV format detected');
      return value.split(',').map(v => v.trim()).filter(Boolean);
    }

    // Single value
    return [value];
  }

  return [value];
}
Enter fullscreen mode Exit fullscreen mode

The Complete Bridge: Full Renderer Implementation

Here is the complete Preact renderer that bridges to React. This is the actual implementation, cleaned up and annotated:

// DropdownFieldRenderer.js

import React from 'react';
import { createRoot } from 'react-dom/client';
import { FormStaticSelectDropdown } from './components/FormStaticSelectDropdown';
import { FormApiSingleSelectDropdown } from './components/FormApiSingleSelectDropdown';
import { FormApiMultiSelectDropdown } from './components/FormApiMultiSelectDropdown';
import { FormMultiMegaDropdown } from './components/FormMultiMegaDropdown';
import { FormSingleMegaDropdown } from './components/FormSingleMegaDropdown';

import {
  Errors,
  Description,
  Label,
  useService,
  FormContext
} from '@bpmn-io/form-js';
import { html, useEffect, useLayoutEffect, useRef, useContext } from 'diagram-js/lib/ui';

export const dropdownType = 'dropdown';

// ✅ Module-level Map — shared across all renderer instances
const reactRoots = new Map();

// ✅ Global instance counter — ensures unique container IDs
const componentInstanceId = (() => {
  let counter = 0;
  return () => `instance-${++counter}`;
})();

function prefixId(id, formId) {
  if (formId) return `fjs-form-${formId}-${id}`;
  return `fjs-form-${id}`;
}

// ============================================================
// The Preact renderer — the bridge
// ============================================================
export function DropdownFieldRenderer(props) {
  const {
    disabled,
    errors = [],
    field,
    readonly,
    value,
    onChange,
    formData = {}
  } = props;

  const { formId } = useContext(FormContext);

  // ✅ Get eventBus — optional, don't throw if unavailable
  let eventBus;
  try {
    eventBus = useService('eventBus', false);
  } catch (error) {
    eventBus = null;
  }

  const { id, label, description, dropdown_config = {}, validate = {} } = field;

  // ✅ Unique instance ID — stable across re-renders of this instance
  const instanceId = useRef(componentInstanceId()).current;
  const fieldId = prefixId(id, formId);
  const containerId = `${fieldId}-dropdown-container-${instanceId}`;
  const errorMessageId = errors.length === 0 ? undefined : `${fieldId}-error-message`;

  // ✅ Refs for values that change but shouldn't trigger re-mounts
  const onChangeRef = useRef(onChange);
  const fieldRef = useRef(field);
  const isMountedRef = useRef(false);
  const eventBusRef = useRef(eventBus);

  // Keep refs current without triggering effects
  useEffect(() => {
    onChangeRef.current = onChange;
    fieldRef.current = field;
    if (eventBus) eventBusRef.current = eventBus;
  });

  // ============================================================
  // MOUNTING — useLayoutEffect for synchronous mount before paint
  // ============================================================
  useLayoutEffect(() => {
    const container = document.getElementById(containerId);
    if (!container) {
      console.warn(`Container ${containerId} not found during mount`);
      return;
    }

    isMountedRef.current = true;

    const { Component, props: componentProps, message } = selectComponentAndProps(
      fieldRef.current,
      value,
      (newValue) => {
        if (onChangeRef.current) {
          onChangeRef.current({ value: newValue, field: fieldRef.current });
        }
      },
      disabled,
      readonly,
      errors,
      formData,
      fieldId
    );

    let rootData = reactRoots.get(containerId);

    try {
      if (!rootData) {
        if (Component) {
          // ✅ No existing root — create new React root
          const root = createRoot(container);
          root.render(React.createElement(Component, componentProps));
          reactRoots.set(containerId, {
            root,
            fieldId: id,
            instanceId,
            mounted: true,
            container,
            generation: 1
          });
        } else {
          // No component — show configuration message
          container.innerHTML = `<div style="color: #856404; background: #fff3cd; border: 1px solid #ffeaa7; padding: 12px; border-radius: 4px; font-size: 13px;">${message || '⚠️ Unsupported dropdown configuration'}</div>`;
        }
      } else {
        // ✅ Root exists — re-render (don't create new root)
        rootData.mounted = true;
        rootData.generation += 1; // ✅ Increment generation on remount
        if (Component) {
          rootData.root.render(React.createElement(Component, componentProps));
        } else {
          container.innerHTML = `<div style="...">${message || '⚠️ Unsupported configuration'}</div>`;
        }
      }
    } catch (error) {
      console.error(`Error rendering React component for ${containerId}:`, error);
    }
  }, [containerId, id, field, value, onChange, disabled, readonly, errors, formData, instanceId, fieldId]);

  // ============================================================
  // CLEANUP — useEffect with deferred unmount
  // ============================================================
  useEffect(() => {
    isMountedRef.current = true;

    return () => {
      isMountedRef.current = false;
      const rootData = reactRoots.get(containerId);

      if (rootData) {
        rootData.mounted = false;

        // ✅ Capture generation at unmount time
        const capturedGeneration = rootData.generation;

        // ✅ Deferred unmount — React needs a tick to flush
        setTimeout(() => {
          const currentRootData = reactRoots.get(containerId);

          // ✅ Only unmount if:
          // - Root still tracked (not already cleaned up)
          // - Still marked as unmounted (no remount happened)
          // - Generation unchanged (no remount within 150ms window)
          if (currentRootData &&
              !currentRootData.mounted &&
              currentRootData.generation === capturedGeneration) {
            try {
              const containerStillExists = document.getElementById(containerId);
              if (containerStillExists && containerStillExists.parentNode) {
                currentRootData.root.unmount();
              }
              reactRoots.delete(containerId);
            } catch (error) {
              console.warn(`Error during cleanup for ${containerId}:`, error);
              reactRoots.delete(containerId);
            }
          }
        }, 150);
      }
    };
  }, [containerId]);

  // ============================================================
  // RENDER — the Preact template with mount point for React
  // ============================================================
  return html`
    <div class="fjs-form-field fjs-form-field-dropdown ${errors.length > 0 ? 'fjs-has-errors' : ''}">
      <${Label}
        id=${fieldId}
        label=${label}
        required=${validate?.required || false} />

      <!-- ✅ This div is the React mount point -->
      <!-- React renders into this div, Preact owns the surrounding structure -->
      <div
        id=${containerId}
        class="fjs-enhanced-dropdown-container ${errors.length > 0 ? 'has-error' : ''}"
        style="${errors.length > 0 ? 'border: 1px solid #dc3545; border-radius: 4px;' : ''}"
      ></div>

      <${Description} description=${description} />
      <${Errors} errors=${errors} id=${errorMessageId} />
    </div>
  `;
}

// ✅ Required by Form-JS — declares DI dependencies
DropdownFieldRenderer.$inject = ['formFieldRegistry', 'formData'];

// ✅ Required by Form-JS — field type configuration
DropdownFieldRenderer.config = {
  type: dropdownType,
  label: 'Dropdown',
  group: 'selection',
  keyed: true,
  iconUrl: `data:image/svg+xml,...`,
  propertiesPanelEntries: ['key', 'label', 'description', 'disabled', 'readonly'],
  create: (options = {}) => ({
    type: dropdownType,
    label: options.label || 'Dropdown',
    key: options.key || undefined,
    dropdown_config: {
      is_multi: false,
      is_searchable: true,
      is_mega_dropdown: false,
      data_source: 'manual',
      filter_options: [],
      conditional_rules: [],
      priority_mode: true,
      static_values: []
    },
    ...options
  })
};
Enter fullscreen mode Exit fullscreen mode

The Minimal Bridge Template

If you have one React component to bridge (not five), here is the minimal template:

// MinimalBridge.js
// A Preact renderer that mounts a single React component

import React from 'react';
import { createRoot } from 'react-dom/client';
import { MyReactComponent } from './MyReactComponent';
import { html, useEffect, useLayoutEffect, useRef, useContext } from 'diagram-js/lib/ui';
import { FormContext, Label, Errors, Description } from '@bpmn-io/form-js';

// ✅ Module-level — tracks all active React roots
const reactRoots = new Map();

// ✅ Global counter — unique instance IDs
const nextInstanceId = (() => {
  let n = 0;
  return () => `inst-${++n}`;
})();

export function MyFieldRenderer(props) {
  const { field, errors = [], value, onChange, disabled, readonly } = props;
  const { formId } = useContext(FormContext);

  const instanceId = useRef(nextInstanceId()).current;
  const fieldId = formId ? `fjs-form-${formId}-${field.id}` : `fjs-form-${field.id}`;
  const containerId = `${fieldId}-react-container-${instanceId}`;

  // Stable refs — don't trigger re-mounts when these change
  const onChangeRef = useRef(onChange);
  const isMountedRef = useRef(false);
  useEffect(() => { onChangeRef.current = onChange; });

  // ✅ Mount: useLayoutEffect — synchronous, before paint
  useLayoutEffect(() => {
    const container = document.getElementById(containerId);
    if (!container) return;

    const componentProps = {
      value,
      disabled,
      readOnly: readonly,
      onChange: (newValue) => {
        if (onChangeRef.current) {
          onChangeRef.current({ value: newValue, field });
        }
      }
    };

    let rootData = reactRoots.get(containerId);

    if (!rootData) {
      const root = createRoot(container);
      root.render(React.createElement(MyReactComponent, componentProps));
      reactRoots.set(containerId, { root, mounted: true, generation: 1 });
    } else {
      rootData.mounted = true;
      rootData.generation += 1; // ✅ Increment on remount
      rootData.root.render(React.createElement(MyReactComponent, componentProps));
    }
  }, [containerId, field, value, disabled, readonly]);

  // ✅ Cleanup: useEffect — deferred unmount
  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
      const rootData = reactRoots.get(containerId);
      if (!rootData) return;

      rootData.mounted = false;
      const capturedGeneration = rootData.generation; // ✅ Capture now

      setTimeout(() => {
        const current = reactRoots.get(containerId);
        // ✅ Three conditions: exists, unmounted, generation unchanged
        if (current && !current.mounted && current.generation === capturedGeneration) {
          try {
            const el = document.getElementById(containerId);
            if (el?.parentNode) current.root.unmount();
          } catch (e) {
            console.warn('Cleanup error:', e);
          }
          reactRoots.delete(containerId);
        }
      }, 150);
    };
  }, [containerId]);

  // ✅ Render: Preact template with React mount point
  return html`
    <div class="fjs-form-field">
      <${Label} id=${fieldId} label=${field.label} required=${field.validate?.required} />

      <!-- React mounts into this div -->
      <div id=${containerId}></div>

      <${Description} description=${field.description} />
      <${Errors} errors=${errors} />
    </div>
  `;
}

MyFieldRenderer.config = {
  type: 'myfield',
  label: 'My Field',
  group: 'basic-input',
  keyed: true,
  propertiesPanelEntries: ['key', 'label', 'description'],
  create: (options = {}) => ({
    type: 'myfield',
    label: options.label || 'My Field',
    key: options.key || undefined,
    ...options
  })
};
Enter fullscreen mode Exit fullscreen mode

The Tradeoffs

Two React trees in one page. React's context system doesn't cross the bridge. A React Context provider above the Form-JS form does not provide values to React components mounted inside Preact renderers. Each React component inside the bridge is a separate React tree. If your React components need shared context (theme, auth, i18n), you must provide it explicitly as props or create a context provider wrapper inside each bridge render call.

The 150ms window is a guess. The deferred cleanup uses 150ms because that was long enough in practice to prevent false unmounts during Form-JS's re-render cycles. It's not based on a React internal guarantee. In a heavily loaded application or on a slow device, Form-JS might complete its re-render in more than 150ms, and the generation counter would be the only protection against incorrect cleanup.

The generation counter only solves the race condition, not the root cause. The root cause is that Form-JS can unmount and remount a Preact component for the same DOM node in rapid succession. A better approach would be to hook into Form-JS's rendering lifecycle to know when a re-render is starting and when it's complete. That API doesn't exist, so the generation counter is the pragmatic fix.

document.getElementById is global. The _findFieldElement equivalent here uses document.getElementById(containerId) to find the container. If two Form-JS forms are on the same page with the same field key, their container IDs would be different (they include formId) but the global document query is still technically risky. The formId in the container ID prevents collisions in practice.

React bundle size. Bridging React into a Preact application means shipping both frameworks. In my case the React components were already in the project — the dropdown components predated the Form-JS integration. If you're starting fresh, write Preact components instead. The bridge is for situations where rewriting is not practical.


What Comes Next

The bridge handles the mounting and lifecycle of React components inside Preact renderers. The next challenge is the form editor — in the editor context you also have Preact, but the properties panel components use Preact hooks directly, not React. When your properties panel needs to show complex UI (like a searchable dropdown for selecting API endpoints), you're building in Preact without React.

Article 10 covers building a full SearchableSelect component in Preact for the properties panel — keyboard navigation, click-outside detection, loading states, and why you can't just use a React library here.


This is Part 9 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 react preact custom-components javascript devex

Top comments (0)