DEV Community

Cover image for Replacing a Built-In Field Type: Swapping Form-JS DateTime with rsuite DatePicker
Sam Abaasi
Sam Abaasi

Posted on

Replacing a Built-In Field Type: Swapping Form-JS DateTime with rsuite DatePicker

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


Form-JS ships with a datetime field. It works. It's a text input with some validation. Users type a date like "2024-01-15" and the form stores it. For simple use cases, this is sufficient.

For a process management application where users are selecting dates from calendars — comparing deadlines, scheduling activities, entering timestamps — a text input is not sufficient. Users make typos. They enter dates in wrong formats. They don't know if "01/15/2024" or "2024-01-15" is expected. Every date entry becomes a data quality problem.

I replaced the built-in datetime field with rsuite's DatePicker — a proper calendar-based date picker with month navigation, time selection, format enforcement, and a clean visual design. This article documents the replacement process, the specific problems you encounter when bridging a React date picker into Form-JS's Preact renderer, and the connection to the FEEL validation system that makes it worth the effort.


Replace vs Extend

Before diving into the implementation, the decision between replacing and extending:

Extend when the built-in field's core behavior is correct and you need to add properties, configuration options, or behavior on top. Adding a "mask" property to a textfield, adding conditional validation to a number field — these are extension scenarios. The field renders the same way; you're adding knobs.

Replace when the built-in field's core UX is fundamentally wrong for your use case. The datetime field's text input is not wrong — it's a valid implementation of date input. But for an application where date accuracy is business-critical and users are not technical, a text input creates problems that properties panel configuration cannot solve. No amount of adding validation expressions to a text input makes it as user-friendly as a calendar picker.

The line between extend and replace is subjective. My rule: if you find yourself adding so much runtime behavior to compensate for the field's UX limitations that you're essentially rebuilding the field in evaluators and CSS, replace it instead.


The Mechanics of Replacement

Form-JS maps field types to renderer classes through the formFields service. When a field with type: "datetime" appears in the schema, the formFields registry finds the renderer for "datetime" and uses it.

Your replacement registers a new renderer for the same type key. The last registration wins:

// The built-in registration (inside form-js)
formFields.register('datetime', BuiltInDateTimeField);
formFields.register('date', BuiltInDateField);
formFields.register('time', BuiltInTimeField);

// Your replacement — registered later, in your additionalModules
formFields.register('datetime', YourDateTimeField);
formFields.register('date', YourDateField);
formFields.register('time', YourTimeField);
Enter fullscreen mode Exit fullscreen mode

The registration happens in the same wrapper class pattern used for custom fields from Article 2:

// DateTimeFieldRendererExtension.js

class DateTimeFieldRendererClass {
  constructor(formFields) {
    // Register for all three subtypes
    formFields.register('datetime', DateTimeFieldRenderer);
    formFields.register('date', DateTimeFieldRenderer);
    formFields.register('time', DateTimeFieldRenderer);
  }
}
DateTimeFieldRendererClass.$inject = ['formFields'];

export default {
  __init__: ['dateTimeFieldRenderer'],
  dateTimeFieldRenderer: ['type', DateTimeFieldRendererClass]
};
Enter fullscreen mode Exit fullscreen mode

The same DateTimeFieldRenderer handles all three subtypes. It receives a field prop with field.type set to 'datetime', 'date', or 'time', and uses that to select the appropriate format and behavior.

One important difference from custom fields: You don't need a config static property on your replacement renderer. config is for registering new field types that appear in the editor palette. Replacement renderers just override the renderer for existing types — the type is already registered in the schema and in the editor.


The Problem: Installing rsuite

rsuite is a React component library. Your renderer is a Preact component (using html from diagram-js/lib/ui). This is the same bridge problem from Article 9 — React inside Preact.

The DateTime component I show in this article is a pure React TypeScript component that lives in the React side of the bridge. The Preact renderer (DateTimeFieldRenderer) creates a mount point div and uses createRoot to mount the React component into it, exactly like the dropdown bridge from Article 9.

First, install rsuite:

npm install rsuite
Enter fullscreen mode Exit fullscreen mode

rsuite requires its CSS:

import 'rsuite/DatePicker/styles/index.css';
import 'rsuite/dist/rsuite.css';
Enter fullscreen mode Exit fullscreen mode

Import these in your main application entry or in the DateTime component file. In a Vite/webpack project, these CSS imports are processed during build.


The React DateTime Component

The core component handles all three subtypes:

// DateTime.tsx

import React, { useMemo, useRef, useEffect, useState } from 'react';
import DatePicker from 'rsuite/DatePicker';
import 'rsuite/DatePicker/styles/index.css';
import 'rsuite/dist/rsuite.css';

interface DateTimeProps {
  field: any;
  inputId: string;
  value: string;
  onChange: (newValue: string) => void;
  disabled?: boolean;
  readonly?: boolean;
  subtype?: 'date' | 'datetime' | 'time';
  className?: string;
  errors?: string[];
}

export const DateTime: React.FC<DateTimeProps> = ({
  field,
  inputId,
  value,
  onChange,
  disabled = false,
  readonly = false,
  subtype = 'date',
  className = '',
  errors = []
}) => {
  const containerRef = useRef<HTMLDivElement | null>(null);

  // ✅ The container-ready pattern — explained in detail below
  const [containerReady, setContainerReady] = useState(false);

  useEffect(() => {
    if (containerRef.current) {
      setContainerReady(true);
    }
  }, []);

  const isRequired = field?.required || false;

  // ============================================================
  // FORMAT VALIDATION
  // ============================================================

  const isValidTimeFormat = (time: string): boolean => {
    return /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/.test(time);
  };

  const isValidDateFormat = (date: string): boolean => {
    return /^\d{4}-\d{2}-\d{2}$/.test(date);
  };

  const isValidDateTimeFormat = (datetime: string): boolean => {
    return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime);
  };

  // ✅ Check if the stored value matches the expected format
  const hasValidFormat = useMemo(() => {
    if (!value) return true; // Empty is valid — not the same as wrong format

    if (subtype === 'time') return isValidTimeFormat(value);
    if (subtype === 'date') return isValidDateFormat(value);
    if (subtype === 'datetime') return isValidDateTimeFormat(value);

    return true;
  }, [value, subtype]);

  const hasErrors = !hasValidFormat || (errors?.length ?? 0) > 0;
  const shouldShowError = hasErrors;
  const shouldShowRequired = isRequired && !value;

  // ============================================================
  // VALUE PARSING — string → Date for rsuite
  // ============================================================

  const parseValue = (): Date | null => {
    if (!value || !hasValidFormat) return null;

    if (subtype === 'time' && isValidTimeFormat(value)) {
      const [hours, minutes, seconds] = value.split(':').map(Number);
      const date = new Date();
      date.setHours(hours, minutes, seconds, 0);
      return date;
    }

    if (subtype === 'date' && isValidDateFormat(value)) {
      // ✅ Append T00:00:00 to prevent timezone offset issues
      return new Date(value + 'T00:00:00');
    }

    if (subtype === 'datetime' && isValidDateTimeFormat(value)) {
      // ✅ Replace space with T for ISO format
      return new Date(value.replace(' ', 'T'));
    }

    return null;
  };

  // ============================================================
  // VALUE FORMATTING — Date → string for form data
  // ============================================================

  const formatDate = (date: Date | null): string => {
    if (!date) return '';

    const pad = (num: number) => String(num).padStart(2, '0');

    if (subtype === 'time') {
      return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
    }

    const year = date.getFullYear();
    const month = pad(date.getMonth() + 1);
    const day = pad(date.getDate());

    if (subtype === 'date') {
      return `${year}-${month}-${day}`;
    }

    // datetime
    const hours = pad(date.getHours());
    const minutes = pad(date.getMinutes());
    const seconds = pad(date.getSeconds());
    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
  };

  const handleDateChange = (date: Date | null) => {
    const formattedDate = formatDate(date);
    onChange(formattedDate);
  };

  // ============================================================
  // RSUITE CONFIGURATION — subtype selects format string
  // ============================================================

  const selectedDate = parseValue();
  const isTimeOnly = subtype === 'time';
  const isDateTime = subtype === 'datetime';

  const getFormat = () => {
    if (isDateTime) return 'yyyy-MM-dd HH:mm:ss';
    if (isTimeOnly) return 'HH:mm:ss';
    return 'yyyy-MM-dd';
  };

  return (
    <div
      ref={containerRef}
      className={className}
      style={{ width: '100%', position: 'relative' }}
    >
      <div
        className={`${shouldShowError ? 'datetime-error-wrapper' : ''} ${
          shouldShowRequired ? 'datetime-required-wrapper' : ''
        }`}
        style={{ width: '100%' }}
      >
        <DatePicker
          id={inputId}
          value={selectedDate}

          // ✅ The container prop — explained below
          container={
            containerReady && containerRef.current
              ? containerRef.current
              : undefined
          }

          onChange={handleDateChange}
          disabled={disabled || readonly}
          format={getFormat()}
          cleanable={true}
          oneTap={false}
          placement="bottomStart"
          ranges={[
            {
              label: 'Now',
              value: new Date()
            }
          ]}
          style={{ width: '100%' }}
        />
      </div>

      {/* ✅ Scoped CSS for error styling — avoids global selector conflicts */}
      <style>{`
        .datetime-error-wrapper .rs-picker-toggle-wrapper {
          border: 2px solid #f44336 !important;
          border-radius: 6px !important;
        }
        .datetime-error-wrapper .rs-picker-toggle-wrapper:hover {
          border-color: #f44336 !important;
        }
        .datetime-required-wrapper .rs-picker-toggle-wrapper {
          border: 2px solid #f44336 !important;
          border-radius: 6px !important;
        }
      `}</style>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The Container-Ref Problem

This is the most Preact/React bridge-specific problem in the entire replacement.

rsuite's DatePicker renders its calendar popup in a portal. By default, the portal renders into document.body. This causes two problems inside Form-JS's layout:

Problem 1: z-index conflicts. Form-JS's form container may have overflow: hidden or specific z-index values. A popup rendered in document.body appears above everything — including other UI elements that should be on top of it — or gets clipped by the form container's overflow settings.

Problem 2: Positioning errors. The calendar popup positions itself relative to the trigger element. When the popup is in document.body but the trigger is deep inside Form-JS's layout, CSS transform on any ancestor element breaks the position: fixed calculations that the popup uses. The calendar appears in the wrong position.

rsuite's container prop solves this: pass a DOM element and rsuite renders the popup portal inside that element instead of document.body. The popup then positions relative to that container, which is inside the form layout where z-index and transforms are predictable.

<DatePicker
  container={myContainerDomElement}
  // ...
/>
Enter fullscreen mode Exit fullscreen mode

The problem: you can't pass containerRef.current directly to container because containerRef.current is null on the first render. React refs are populated after the first paint. On the first render, ref.current is null. rsuite receives null as the container prop and falls back to document.body — the broken behavior you were trying to avoid.

The fix: the containerReady state pattern.

const containerRef = useRef<HTMLDivElement | null>(null);
const [containerReady, setContainerReady] = useState(false);

// ✅ Set containerReady after first render when ref is populated
useEffect(() => {
  if (containerRef.current) {
    setContainerReady(true);
  }
}, []); // Empty deps — runs once after first render

// ✅ Only pass container when it actually exists
container={
  containerReady && containerRef.current
    ? containerRef.current
    : undefined
}
Enter fullscreen mode Exit fullscreen mode

The sequence:

First render:
  containerReady = false
  containerRef.current = null
  container={undefined} → rsuite uses document.body

useEffect fires (after first render):
  containerRef.current = <the div element>
  setContainerReady(true)

Second render (triggered by state update):
  containerReady = true
  containerRef.current = <the div element>
  container={containerRef.current} → rsuite uses the div
Enter fullscreen mode Exit fullscreen mode

There's a brief moment on first render where rsuite uses document.body. This is imperceptible to users — the first render doesn't show the popup (the user hasn't clicked anything yet). By the time the user clicks to open the calendar, containerReady is true and the popup renders in the correct container.

Why not just pass null or the ref itself?

rsuite's container prop is typed as HTMLElement | (() => HTMLElement) | null. Passing null means "no container" (use document.body). Passing a function () => containerRef.current would work but risks being called before the ref is populated if rsuite evaluates the function immediately. The containerReady state is the most reliable pattern.


String Storage — Never Date Objects

Form data must be serializable. Date objects are not serializable JSON. The form's schema data, submitted data, and pre-populated data are all JSON objects. A Date object in form data survives in memory during the form session but fails when the data is serialized, sent to a server, or stored.

The storage format is always strings:

Subtype Storage format Example
date YYYY-MM-DD "2024-01-15"
datetime YYYY-MM-DD HH:mm:ss "2024-01-15 10:30:00"
time HH:mm:ss "10:30:00"

The formatDate function converts Date → string immediately when rsuite's onChange fires. The parseValue function converts string → Date immediately when passing to rsuite's value prop. Date objects only exist transiently, during the render cycle.

The timezone pitfall:

// ❌ Without explicit timezone handling
new Date('2024-01-15')
// → Mon Jan 15 2024 00:00:00 UTC
// → In GMT+5: Mon Jan 15 2024 05:00:00 local
// → date.getDate() returns 15 ✅
// → In GMT-5: Sun Jan 14 2024 19:00:00 local
// → date.getDate() returns 14 ❌ — WRONG DATE
Enter fullscreen mode Exit fullscreen mode
// ✅ With T00:00:00 to force local time interpretation
new Date('2024-01-15T00:00:00')
// → Mon Jan 15 2024 00:00:00 local (no timezone conversion)
// → date.getDate() returns 15 in all timezones ✅
Enter fullscreen mode Exit fullscreen mode

The T00:00:00 suffix forces JavaScript to interpret the date string as local time rather than UTC. Without it, date-only strings are parsed as UTC midnight, which shifts to the previous day for users in negative UTC offsets (Americas).

The datetime format with a space:

Form-JS's datetime storage format uses a space between date and time: "2024-01-15 10:30:00". This is different from ISO 8601 format which uses T: "2024-01-15T10:30:00". When parsing back to a Date, new Date("2024-01-15 10:30:00") works in modern browsers but is technically invalid ISO 8601. The safe approach is to replace the space with T before parsing:

new Date(value.replace(' ', 'T'))
// "2024-01-15 10:30:00" → new Date("2024-01-15T10:30:00") ✅
Enter fullscreen mode Exit fullscreen mode

The Format Validation Check

When the component receives a value from form data, it might be in an unexpected format. This happens when:

  • Forms are migrated from a previous datetime field implementation with a different format
  • Data is pre-populated from an external system with its own date format
  • A user manually edited a date through a form that accepted freeform text

Rather than crashing or silently showing nothing, the hasValidFormat check provides immediate visual feedback:

const hasValidFormat = useMemo(() => {
  if (!value) return true; // Empty is valid — separate from wrong format

  if (subtype === 'time') return isValidTimeFormat(value);
  if (subtype === 'date') return isValidDateFormat(value);
  if (subtype === 'datetime') return isValidDateTimeFormat(value);

  return true;
}, [value, subtype]);
Enter fullscreen mode Exit fullscreen mode

When hasValidFormat is false:

  • parseValue() returns null — the DatePicker shows as empty
  • The error CSS class applies — the picker gets a red border
  • The form can still be filled and submitted (the error styling is visual feedback, not blocking)

The DatePicker showing as empty rather than attempting to display a malformed date prevents confusing behavior like showing January 1970 for an invalid date or throwing a runtime error.


The Preact Renderer Wrapper

The DateTime React component is mounted into a Preact renderer using the same bridge pattern from Article 9:

// DateTimeFieldRenderer.js — the Preact side of the bridge

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

const reactRoots = new Map();

const componentInstanceId = (() => {
  let counter = 0;
  return () => `dt-instance-${++counter}`;
})();

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

export function DateTimeFieldRenderer(props) {
  const {
    disabled,
    errors = [],
    field,
    readonly,
    value = '',
    onChange
  } = props;

  const { formId } = useContext(FormContext);
  const { id, label, description, validate = {} } = field;

  const instanceId = useRef(componentInstanceId()).current;
  const fieldId = prefixId(id, formId);
  const containerId = `${fieldId}-datetime-container-${instanceId}`;
  const errorMessageId = errors.length === 0 ? undefined : `${fieldId}-error-message`;

  const onChangeRef = useRef(onChange);
  useEffect(() => { onChangeRef.current = onChange; });

  // ✅ Mount React DateTime into Preact renderer
  useLayoutEffect(() => {
    const container = document.getElementById(containerId);
    if (!container) return;

    const componentProps = {
      field,
      inputId: fieldId,
      value: value || '',
      onChange: (newValue) => {
        if (onChangeRef.current) {
          onChangeRef.current({ field, value: newValue });
        }
      },
      disabled,
      readonly,
      // ✅ Pass the field type as subtype
      // field.type is 'date', 'datetime', or 'time'
      subtype: field.type,
      errors
    };

    let rootData = reactRoots.get(containerId);

    if (!rootData) {
      const root = createRoot(container);
      root.render(React.createElement(DateTime, componentProps));
      reactRoots.set(containerId, { root, mounted: true, generation: 1 });
    } else {
      rootData.mounted = true;
      rootData.generation += 1;
      rootData.root.render(React.createElement(DateTime, componentProps));
    }
  }, [containerId, field, value, disabled, readonly, errors, fieldId]);

  useEffect(() => {
    return () => {
      const rootData = reactRoots.get(containerId);
      if (!rootData) return;

      rootData.mounted = false;
      const capturedGeneration = rootData.generation;

      setTimeout(() => {
        const current = reactRoots.get(containerId);
        if (current && !current.mounted && current.generation === capturedGeneration) {
          try {
            const el = document.getElementById(containerId);
            if (el?.parentNode) current.root.unmount();
          } catch (e) {}
          reactRoots.delete(containerId);
        }
      }, 150);
    };
  }, [containerId]);

  return html`
    <div class="fjs-form-field fjs-form-field-datetime ${errors.length > 0 ? 'fjs-has-errors' : ''}">
      <${Label}
        id=${fieldId}
        label=${label}
        required=${validate?.required || false} />

      <!-- React DateTime mounts here -->
      <div
        id=${containerId}
        class="fjs-datetime-container"
        style="width: 100%;"
      ></div>

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

// ✅ No config.type — this is a replacement, not a new type
// The type key is set during registration in the module
Enter fullscreen mode Exit fullscreen mode

The critical difference from Article 9's dropdown renderer: no config static property with a type key. The dropdown renderer defines a new custom type. The datetime renderer replaces an existing type. The type registration happens in the wrapper class that calls formFields.register('datetime', DateTimeFieldRenderer) — not in a config property on the renderer itself.


Connecting to the FEEL Validation System

The DateTimeValidationEvaluator from Article 8 validates datetime fields using FEEL expressions like = value >= today(). It identifies datetime fields by checking component.type:

// In DateTimeValidationEvaluator:
const DATETIME_TYPES = ['date', 'datetime', 'time'];

const datetimeComponents = components.filter(c =>
  DATETIME_TYPES.includes(c.type) && c.key
);
Enter fullscreen mode Exit fullscreen mode

Because your replacement renderer keeps the same type value ('date', 'datetime', 'time'), the evaluator identifies your replaced fields automatically. No changes to the evaluator are needed.

What the connection looks like in practice:

The form designer configures a date field with a FEEL validation rule: = value >= today(). The DateTimeValidationEvaluator runs on every changed event. It finds the date field, reads its current value from form data, prepares a context with today() as a function, and evaluates value >= today().

Your replacement renderer stores values as "YYYY-MM-DD" strings. The evaluator's prepareContext (from Article 4) converts date strings to Date objects before FEEL evaluation. The _coerceDateValue method handles this:

// In DateTimeValidationEvaluator._getRelevantFormData:
const dateComponents = this._getAllComponents(schema?.components || [])
  .filter(c => DATETIME_TYPES.includes(c.type) && c.key);

dateComponents.forEach(component => {
  const rawValue = data[component.key];
  if (typeof rawValue === 'string' && rawValue.trim() !== '') {
    const converted = this._coerceDateValue(rawValue, component.type);
    if (converted !== null) {
      context[component.key] = converted;
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

The evaluator converts "2024-01-15" to a Date object before putting it in the FEEL context, so value >= today() compares dates correctly.

One requirement your replacement must meet: store values in the formats the evaluator expects. The evaluator's _coerceDateValue handles:

  • date type: "YYYY-MM-DD" pattern
  • datetime type: "YYYY-MM-DD HH:mm:ss" (space-separated)
  • time type: "HH:mm:ss" (returned as-is, not converted to Date)

Your formatDate function produces exactly these formats. The connection is implicit — same type key, same storage format — and requires no explicit wiring.


The Module Registration

// DateTimeFieldModule.js

import { DateTimeFieldRenderer } from './DateTimeFieldRenderer';

class DateTimeFieldRendererClass {
  constructor(formFields) {
    formFields.register('datetime', DateTimeFieldRenderer);
    formFields.register('date', DateTimeFieldRenderer);
    formFields.register('time', DateTimeFieldRenderer);
  }
}
DateTimeFieldRendererClass.$inject = ['formFields'];

// ✅ Runtime module — used in the form player
export const DateTimeFieldRenderExtension = {
  __init__: ['dateTimeFieldRenderer'],
  dateTimeFieldRenderer: ['type', DateTimeFieldRendererClass]
};

// ✅ Editor module — used in the form editor (for preview rendering)
// No new properties panel entries needed — the built-in properties
// (key, label, description, disabled, readonly) still apply
export const DateTimePropertiesPanelExtension = {
  // No __init__ needed if you're only using built-in properties
  // Add entries here if you need custom validation rule configuration
};
Enter fullscreen mode Exit fullscreen mode

In your CustomForm and CustomFormEditor:

// CustomForm.js
const form = new Form({
  container: document.getElementById('form'),
  additionalModules: [
    DateTimeFieldRenderExtension,
    // ... other modules
  ]
});

// CustomFormEditor.js
const editor = new FormEditor({
  container: document.getElementById('editor'),
  additionalModules: [
    DateTimeFieldRenderExtension,
    // ... other modules
  ]
});
Enter fullscreen mode Exit fullscreen mode

The editor needs the replacement renderer for preview rendering. When the form designer adds a datetime field and looks at the form canvas preview, they should see the rsuite DatePicker, not the built-in text input.


The Tradeoffs

rsuite's bundle size. rsuite is a full component library — adding it brings in approximately 800KB of JavaScript and CSS (uncompressed). If you're only using the DatePicker, most of that bundle is unused. A tree-shaking-friendly import (import DatePicker from 'rsuite/DatePicker') reduces this to approximately 150KB. Enable tree shaking in your bundler configuration and import from the specific component path, not the root rsuite package.

rsuite's CSS may conflict with Form-JS's CSS. rsuite ships global CSS that styles form elements. If Form-JS's inputs are inside rsuite's CSS scope, their styling may change. The fix is CSS scoping — wrap rsuite components in a container with a class and scope rsuite's CSS to that class. In practice, rsuite's CSS is specific to .rs-* class names that Form-JS doesn't use, so conflicts are rare.

The container prop introduces a second render cycle. On first render, the DatePicker uses document.body as its popup container. After the useEffect fires, it switches to containerRef.current. If the user opens the calendar immediately on first load (keyboard user pressing Enter on the field), the popup might briefly appear in the wrong position before the second render corrects it. This is a theoretical edge case — the timing is sub-frame in practice.

Replacement means losing built-in behaviors automatically. Form-JS's built-in datetime field may have behaviors your replacement doesn't implement — keyboard shortcut handling, specific ARIA attributes, or integration with form plugins you're not using. Audit the built-in field's source code before replacing and decide which behaviors your replacement needs to preserve.

The storage format must be consistent. If your application already has form submissions with datetime values in a different format (ISO 8601, Unix timestamps, localized strings), your replacement's formatDate output will create a format mismatch. Ensure your backend accepts the format your replacement produces, or add a format adapter.


What Comes Next

You've now seen the full spectrum of custom field development: building new fields from scratch (Article 2), extending properties panels (Articles 5-11), and replacing built-in fields (this article). The next piece is file handling — a domain where Form-JS's default behavior is minimal and building a complete system requires coordinating between browser file APIs, React components, and the form lifecycle.

Article 18 covers file handling across the form lifecycle — local state during selection, cross-component tracking via _pendingFilesRef, and uploading to Camunda's task attachment API on form submission.


This is Part 17 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 datetime rsuite react preact custom-fields javascript devex

Top comments (0)