DEV Community

Cover image for Dynamic Properties Panels: Three Patterns for Conditional Entry Display
Sam Abaasi
Sam Abaasi

Posted on

Dynamic Properties Panels: Three Patterns for Conditional Entry Display

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


The properties panel is not static. When a form designer changes the data source of a dropdown from "Manual" to "API Options," the panel should show completely different configuration entries. When they enable "Dynamic Grid" mode, new file upload settings should appear. When they select a behavior for ticket auto-fill, entries for that specific behavior should appear — and disappear when the behavior is changed.

Form-JS gives you getGroups and an entries array. It does not give you conditional rendering. If you need entries to appear and disappear based on other property values, you build that logic yourself.

I built it three different ways, for three different situations, and each approach has a distinct tradeoff profile. This article documents all three patterns, when each is appropriate, and what breaks when you pick the wrong one.


The Problem

The getGroups function is called every time the properties panel re-renders — which happens every time the user changes any property on the selected field. Your entries array is rebuilt from scratch on every call.

This means you have a choice: decide which entries to include at build time (when you're constructing the entries array), or include all entries always and let individual entries decide whether to render.

Neither approach is obviously correct. The right choice depends on:

  • How many entries change
  • Whether the condition depends on data fetched asynchronously
  • Whether layout jumps matter for the UX
  • Whether the entries depend on each other's values within the same render cycle

I arrived at three distinct patterns through building, observing what broke, and refining. Here they are.


Pattern 1: Build-Time Conditional

Used in: Dropdown properties panel

The situation: The dropdown field has three completely different data source modes — Manual, API Options, and Predefined APIs. Each mode has a completely different set of entries. Manual shows static value management and conditional rules. API Options shows hierarchical API selection and filter configuration. Predefined APIs shows API selector and filter key.

These are not a few entries toggling on and off. They are three entirely different sets of configuration UI. When the designer switches from Manual to API Options, approximately eight entries disappear and six different ones appear.

The pattern: Check the condition before building the entries array. Push different entries based on the condition. The getGroups function builds and returns only the entries that should exist.

function createDropdownConfigurationEntries(field, editField, eventBus) {
  // ✅ Read the current data source value once
  const getDataSourceValue = () => {
    const config = get(field, ['dropdown_config']) || {};
    return config.data_source || 'manual';
  };

  // ✅ These entries always appear — regardless of data source
  const entries = [
    {
      id: `dropdown-is-multi-${field.id}`,
      component: IsMultiEntry,
      getValue: getValue('is_multi'),
      setValue: onChange('is_multi'),
      field,
      isEdited: isCheckboxEntryEdited
    },
    {
      id: `dropdown-data-source-${field.id}`,
      component: DataSourceEntry,
      getValue: getValue('data_source'),
      setValue: onChange('data_source'),
      field,
      isEdited: isSelectEntryEdited
    },
    {
      id: `dropdown-placeholder-${field.id}`,
      component: PlaceholderEntry,
      getValue: getValue('placeholder'),
      setValue: onChange('placeholder'),
      field,
      isEdited: isTextFieldEntryEdited
    },
    {
      id: `dropdown-allow-empty-${field.id}`,
      component: AllowEmptyEntry,
      getValue: getValue('allow_empty'),
      setValue: onChange('allow_empty'),
      field,
      isEdited: isCheckboxEntryEdited
    }
  ];

  // ✅ Pattern 1: build-time conditional
  // Check the condition, push entirely different entries for each case
  const currentDataSource = getDataSourceValue();

  if (currentDataSource === 'manual') {
    // Manual mode: static values, conditional rules, searchable toggle
    entries.push(
      {
        id: `dropdown-is-searchable-${field.id}`,
        component: IsSearchableEntry,
        getValue: getValue('is_searchable'),
        setValue: onChange('is_searchable'),
        field,
        isEdited: isCheckboxEntryEdited
      },
      {
        id: `dropdown-static-values-${field.id}`,
        component: StaticValuesGroup,
        field,
        editField,
        isEdited: () => {
          const config = get(field, ['dropdown_config']) || {};
          return (config.static_values && config.static_values.length > 0);
        }
      },
      {
        id: `dropdown-conditional-rules-${field.id}`,
        component: ConditionalRulesGroup,
        field,
        editField,
        eventBus,
        isEdited: () => {
          const config = get(field, ['dropdown_config']) || {};
          return Array.isArray(config.conditional_rules) &&
                 config.conditional_rules.length > 0;
        }
      }
    );
  } else if (currentDataSource === 'api_options') {
    // API Options mode: completely different set of entries
    entries.push(
      {
        id: `dropdown-hierarchical-api-list-${field.id}`,
        component: HierarchicalApiListEntry,
        getValue: getValue('hierarchical_category_id'),
        setValue: onChange('hierarchical_category_id'),
        field,
        validate: validateHierarchicalCategoryId,
        isEdited: isTextFieldEntryEdited
      },
      {
        id: `dropdown-hierarchical-parent-field-${field.id}`,
        component: HierarchicalParentFieldEntry,
        getValue: getValue('hierarchical_parent_field_key'),
        setValue: onChange('hierarchical_parent_field_key'),
        field,
        eventBus,
        isEdited: isTextFieldEntryEdited
      },
      {
        id: `dropdown-is-searchable-${field.id}`,
        component: IsSearchableEntry,
        getValue: getValue('is_searchable'),
        setValue: onChange('is_searchable'),
        field,
        isEdited: isCheckboxEntryEdited
      },
      {
        id: `dropdown-is-mega-dropdown-${field.id}`,
        component: IsMegaDropdownEntry,
        getValue: getValue('is_mega_dropdown'),
        setValue: onChange('is_mega_dropdown'),
        field,
        isEdited: isCheckboxEntryEdited
      },
      {
        id: `dropdown-field-id-${field.id}`,
        component: FieldIdEntry,
        getValue: getValue('fdaas_field_id'),
        setValue: onChange('fdaas_field_id'),
        field,
        validate: validateFDaaSFieldId,
        isEdited: isTextFieldEntryEdited
      }
    );
  } else if (currentDataSource === 'predefined_apis') {
    // Predefined APIs mode: yet another different set
    entries.push(
      {
        id: `dropdown-is-searchable-${field.id}`,
        component: IsSearchableEntry,
        getValue: getValue('is_searchable'),
        setValue: onChange('is_searchable'),
        field,
        isEdited: isCheckboxEntryEdited
      },
      {
        id: `dropdown-is-mega-dropdown-${field.id}`,
        component: IsMegaDropdownEntry,
        getValue: getValue('is_mega_dropdown'),
        setValue: onChange('is_mega_dropdown'),
        field,
        isEdited: isCheckboxEntryEdited
      },
      {
        id: `dropdown-predefined-api-${field.id}`,
        component: PredefinedApiEntry,
        getValue: getValue('predefined_api_id'),
        setValue: onChange('predefined_api_id'),
        field,
        editField,
        validate: validatePredefinedApiId,
        isEdited: isTextFieldEntryEdited
      },
      {
        id: `dropdown-predefined-parent-field-${field.id}`,
        component: PredefinedParentFieldEntry,
        getValue: getValue('predefined_parent_field_key'),
        setValue: onChange('predefined_parent_field_key'),
        field,
        eventBus,
        isEdited: isTextFieldEntryEdited
      },
      {
        id: `dropdown-predefined-filter-key-${field.id}`,
        component: PredefinedFilterKeyEntry,
        getValue: getValue('predefined_filter_key'),
        setValue: onChange('predefined_filter_key'),
        field,
        isEdited: isTextFieldEntryEdited
      }
    );
  }

  return entries;
}
Enter fullscreen mode Exit fullscreen mode

How it works: getGroups returns a middleware function. That function calls createDropdownConfigurationEntries, which reads field.dropdown_config.data_source and returns only the entries appropriate for that mode. When the designer changes the data source, getGroups is called again, the entries array is rebuilt with the new mode's entries, and the panel re-renders with entirely different controls.

The key insight: Because getGroups is called on every property change, checking the condition at build time is safe. The condition is re-evaluated on every render. You're not caching a stale condition — you're reading the current field state fresh each time.


Pattern 2: Entry-Level Conditional

Used in: Grid field properties panel

The situation: The grid field has a "Enable Excel/CSV Import" checkbox (dynamic_grid). When it's checked, five additional entries appear: Max File Size, Allowed File Types, Required Expected Columns, Optional Expected Columns, and Disable Column Reorder. When it's unchecked, only "Column Names" appears.

This is different from the dropdown situation. The entries don't completely change — only a subset toggles based on one boolean flag. The condition is simple and synchronous.

The pattern: Include all entries in the array always, but check the condition inside the entry factory function. Entries for the disabled state return early or render nothing visible.

function GridConfigurationEntries(field, editField) {
  const onChange = key => value => {
    const currentConfig = get(field, ['grid_config'], {});
    editField(field, 'grid_config', { ...currentConfig, [key]: value });
  };

  const getValue = key => () => get(field, ['grid_config', key]);

  // ✅ Read the condition once — used to decide which entries to add
  const isDynamicGrid = () => get(field, ['grid_config', 'dynamic_grid'], false);

  // ✅ Always included — the toggle itself
  const entries = [
    {
      id: `dynamic-grid-${field.id}`,
      component: DynamicGrid,
      getValue,
      field,
      isEdited: isCheckboxEntryEdited,
      onChange
    }
  ];

  // ✅ Pattern 2: Entry-level conditional
  // Check the condition here in the factory — not inside the component
  if (isDynamicGrid()) {
    // Dynamic grid mode: show file import configuration
    entries.push(
      {
        id: `max-file-size-${field.id}`,
        component: MaxFileSize,
        getValue,
        field,
        isEdited: isNumberFieldEntryEdited,
        onChange
      },
      {
        id: `allowed-file-types-${field.id}`,
        component: AllowedFileTypes,
        getValue,
        field,
        isEdited: isTextFieldEntryEdited,
        onChange
      },
      {
        id: `required-expected-columns-${field.id}`,
        component: RequiredExpectedColumns,
        getValue,
        field,
        isEdited: isTextFieldEntryEdited,
        onChange
      },
      {
        id: `optional-expected-columns-${field.id}`,
        component: OptionalExpectedColumns,
        getValue,
        field,
        isEdited: isTextFieldEntryEdited,
        onChange
      },
      {
        id: `disable-column-reorder-${field.id}`,
        component: DisableColumnReorder,
        getValue,
        field,
        isEdited: isCheckboxEntryEdited,
        onChange
      }
    );
  } else {
    // Static grid mode: show column configuration only
    entries.push({
      id: `grid-columns-${field.id}`,
      component: GridColumns,
      getValue,
      field,
      isEdited: isTextFieldEntryEdited,
      onChange
    });
  }

  // ✅ These always appear regardless of dynamic_grid
  entries.push(
    {
      id: `calculation-total-column-${field.id}`,
      component: CalculationTotalColumn,
      getValue,
      field,
      isEdited: isTextFieldEntryEdited,
      onChange
    },
    {
      id: `add-row-disabled-${field.id}`,
      component: AddRowDisabled,
      getValue,
      field,
      isEdited: isCheckboxEntryEdited,
      onChange
    },
    {
      id: `remove-row-disabled-${field.id}`,
      component: RemoveRowDisabled,
      getValue,
      field,
      isEdited: isCheckboxEntryEdited,
      onChange
    }
  );

  // ✅ Validation rules are always added (they have their own conditional logic)
  const validationRules = get(field, ['grid_config', 'validation_rules'], []);
  entries.push({
    id: `validation-rules-header-${field.id}`,
    component: ValidationRulesHeader,
    getValue,
    field,
    isEdited: () => false,
    onChange
  });

  validationRules.forEach((rule, index) => {
    entries.push(
      {
        id: `validation-rule-${index}-type-${field.id}`,
        component: ValidationRuleType,
        getValue,
        field,
        isEdited: () => false,
        onChange,
        ruleIndex: index
      },
      {
        id: `validation-rule-${index}-columns-${field.id}`,
        component: ValidationRuleColumns,
        getValue,
        field,
        isEdited: isTextFieldEntryEdited,
        onChange,
        ruleIndex: index
      }
    );

    // ✅ Within validation rules: another level of conditional entry building
    // Based on the rule's type property
    const ruleType = get(field, ['grid_config', 'validation_rules', index, 'type'], 'regex');

    if (ruleType === 'regex') {
      entries.push({
        id: `validation-rule-${index}-regex-${field.id}`,
        component: ValidationRuleRegex,
        getValue,
        field,
        isEdited: isTextFieldEntryEdited,
        onChange,
        ruleIndex: index
      });
    } else if (ruleType === 'conditional') {
      entries.push(
        {
          id: `validation-rule-${index}-reference-column-${field.id}`,
          component: ValidationRuleReferenceColumn,
          getValue,
          field,
          isEdited: isTextFieldEntryEdited,
          onChange,
          ruleIndex: index
        },
        {
          id: `validation-rule-${index}-reference-value-${field.id}`,
          component: ValidationRuleReferenceValue,
          getValue,
          field,
          isEdited: isTextFieldEntryEdited,
          onChange,
          ruleIndex: index
        },
        {
          id: `validation-rule-${index}-allowed-pattern-${field.id}`,
          component: ValidationRuleAllowedPattern,
          getValue,
          field,
          isEdited: isTextFieldEntryEdited,
          onChange,
          ruleIndex: index
        }
      );
    }

    entries.push(
      {
        id: `validation-rule-${index}-message-${field.id}`,
        component: ValidationRuleMessage,
        getValue,
        field,
        isEdited: isTextFieldEntryEdited,
        onChange,
        ruleIndex: index
      },
      {
        id: `validation-rule-${index}-is-required-${field.id}`,
        component: ValidationRuleIsRequired,
        getValue,
        field,
        isEdited: isCheckboxEntryEdited,
        onChange,
        ruleIndex: index
      },
      {
        id: `validation-rule-${index}-remove-${field.id}`,
        component: ValidationRuleRemove,
        getValue,
        field,
        isEdited: () => false,
        onChange,
        ruleIndex: index
      }
    );
  });

  entries.push({
    id: `add-validation-rule-${field.id}`,
    component: AddValidationRule,
    getValue,
    field,
    isEdited: () => false,
    onChange
  });

  return entries;
}
Enter fullscreen mode Exit fullscreen mode

How it works: The entry factory function checks isDynamicGrid() and pushes different entries based on the result. When dynamic_grid is false, the factory pushes GridColumns. When true, it pushes five file-import configuration entries instead. The validation rules section also uses Pattern 2 internally — checking ruleType and pushing different entries for 'regex' vs 'conditional' rule types.

Why this differs from Pattern 1: The condition is a single boolean flag, not a multi-value mode switch. The number of entries that change is small and localized. There's no need to restructure the entire entries array — just push a different subset.


Pattern 3: Component-Level Null Return

Used in: TicketAutoFill properties panel

The situation: The ticket auto-fill configuration has a cascading dependency chain. First the designer selects a behavior (Current Ticket or Another Ticket). Based on that selection, different entries appear. Some of those entries fetch data from an API (ticket types, phases, form fields). Some entries should only appear after the user has made a selection in an earlier entry — selections that trigger async fetches.

This is fundamentally different from Patterns 1 and 2. The condition is not just about the current field's schema state — it's about what's been selected in another entry within the same group, and whether async data has loaded. You can't check this at build time in the factory function because the condition state changes after the entries array is built.

The pattern: Include all entries in the array always. Each entry component checks its own visibility condition and returns null if it should be hidden.

// ✅ All entries always included — no conditional pushing
function createTicketAutoFillEntries(field, editField, eventBus, injector) {
  const fieldId = field.id;

  const onChange = (key) => (value) => {
    const currentConfig = get(field, ['ticketAutoFillLogic']) || {};
    const newConfig = { ...currentConfig, [key]: value };

    // Reset dependent fields when behavior changes
    if (key === 'behavior') {
      if (value === 'current_ticket') {
        delete newConfig.ticketFieldKey;
        delete newConfig.ticketType;
        delete newConfig.ticketPhase;
        delete newConfig.fieldName;
      } else if (value === 'another_ticket') {
        delete newConfig.currentTicketFieldName;
      } else {
        // None selected — clear everything
        Object.keys(newConfig).forEach(k => {
          if (k !== 'behavior') delete newConfig[k];
        });
      }
    }

    // Reset downstream when ticket type changes
    if (key === 'ticketType') {
      newConfig.ticketPhase = '';
      newConfig.ticketPhaseDetails = null;
      newConfig.fieldName = '';
    }

    // Reset field name when phase changes
    if (key === 'ticketPhase') {
      newConfig.fieldName = '';
    }

    return editField(field, 'ticketAutoFillLogic', newConfig);
  };

  const getValue = (key) => (element) => {
    const config = get(element || field, ['ticketAutoFillLogic']) || {};
    return config[key] || '';
  };

  // ✅ All entries returned — each component decides its own visibility
  return [
    {
      id: `ticket-autofill-section-header-${fieldId}`,
      component: TicketAutoFillSectionHeader,
      field,
      isEdited: () => {
        const config = get(field, ['ticketAutoFillLogic']) || {};
        return !!config.behavior;
      }
    },
    {
      id: `ticket-autofill-behavior-${fieldId}`,
      component: TicketAutoFillBehaviorEntry,
      getValue: getValue('behavior'),
      setValue: onChange('behavior'),
      field,
      isEdited: () => {
        const config = get(field, ['ticketAutoFillLogic']) || {};
        return !!config.behavior;
      }
    },
    // ✅ These entries always exist in the array
    // but their components return null based on conditions
    {
      id: `ticket-autofill-condition-${fieldId}`,
      component: TicketAutoFillConditionEntry,
      getValue: getValue('condition'),
      setValue: onChange('condition'),
      field,
      eventBus,
      injector,
      isEdited: () => !!get(field, ['ticketAutoFillLogic', 'condition'])
    },
    {
      id: `ticket-autofill-current-field-${fieldId}`,
      component: TicketAutoFillCurrentFieldEntry,
      getValue: getValue('currentTicketFieldName'),
      setValue: onChange('currentTicketFieldName'),
      field,
      eventBus,
      injector,
      isEdited: () => !!get(field, ['ticketAutoFillLogic', 'currentTicketFieldName'])
    },
    {
      id: `ticket-autofill-ticket-field-key-${fieldId}`,
      component: TicketAutoFillTicketFieldKeyEntry,
      getValue: getValue('ticketFieldKey'),
      setValue: onChange('ticketFieldKey'),
      field,
      eventBus,
      injector,
      isEdited: () => !!get(field, ['ticketAutoFillLogic', 'ticketFieldKey'])
    },
    {
      id: `ticket-autofill-ticket-type-${fieldId}`,
      component: TicketAutoFillTicketTypeEntry,
      getValue: getValue('ticketType'),
      setValue: onChange('ticketType'),
      field,
      editField,
      isEdited: () => !!get(field, ['ticketAutoFillLogic', 'ticketType'])
    },
    {
      id: `ticket-autofill-ticket-phase-${fieldId}`,
      component: TicketAutoFillTicketPhaseEntry,
      getValue: getValue('ticketPhase'),
      setValue: onChange('ticketPhase'),
      field,
      editField,
      isEdited: () => !!get(field, ['ticketAutoFillLogic', 'ticketPhase'])
    },
    {
      id: `ticket-autofill-field-name-${fieldId}`,
      component: TicketAutoFillFieldNameEntry,
      getValue: getValue('fieldName'),
      setValue: onChange('fieldName'),
      field,
      isEdited: () => !!get(field, ['ticketAutoFillLogic', 'fieldName'])
    },
    {
      id: `ticket-autofill-populate-options-${fieldId}`,
      component: TicketAutoFillPopulateOptionsEntry,
      getValue: (element) => {
        const config = get(element || field, ['ticketAutoFillLogic']) || {};
        return config.populateOptions || false;
      },
      setValue: onChange('populateOptions'),
      field,
      isEdited: () => !!get(field, ['ticketAutoFillLogic', 'populateOptions'])
    }
  ];
}
Enter fullscreen mode Exit fullscreen mode

The components themselves do the visibility check:

// ✅ Pattern 3: Component returns null when it should be hidden

function TicketAutoFillConditionEntry(props) {
  const { field, getValue, setValue, id, eventBus } = props;

  const config = get(field, ['ticketAutoFillLogic']) || {};
  const behavior = config.behavior;

  // ✅ Return null when condition not met
  // The entry exists in the array but renders nothing
  if (!behavior) {
    return null;
  }

  const debounce = useService('debounceInput', false) ?? ((fn) => fn);
  // ... rest of component

  return FeelEntry({ /* ... */ });
}

function TicketAutoFillCurrentFieldEntry(props) {
  const { field, getValue, setValue, id, eventBus } = props;

  const config = get(field, ['ticketAutoFillLogic']) || {};
  const behavior = config.behavior;

  // ✅ Only show for 'current_ticket' behavior
  if (behavior !== 'current_ticket') {
    return null;
  }

  // ...component implementation...
}

function TicketAutoFillTicketFieldKeyEntry(props) {
  const { field, getValue, setValue, id, eventBus } = props;

  const config = get(field, ['ticketAutoFillLogic']) || {};
  const behavior = config.behavior;

  // ✅ Only show for 'another_ticket' behavior
  if (behavior !== 'another_ticket') {
    return null;
  }

  // ...component implementation...
}

function TicketAutoFillTicketPhaseEntry(props) {
  const { field, getValue, setValue, id, editField } = props;

  const config = get(field, ['ticketAutoFillLogic']) || {};
  const behavior = config.behavior;
  const ticketType = config.ticketType;

  // ✅ Only show when behavior is 'another_ticket' AND ticketType is selected
  // ticketType comes from an earlier entry in the same group
  if (behavior !== 'another_ticket' || !ticketType) {
    return null;
  }

  // ✅ This component fetches data asynchronously
  const [phases, setPhases] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!ticketType) return;

    const fetchPhases = async () => {
      setLoading(true);
      try {
        const response = await deploymentClient.get(
          `/camunda/tickets/types/${encodeURIComponent(ticketType)}/phases/`,
          { params: { exclude_form_content: true } }
        );
        const phasesData = response.data?.phases || [];
        setPhases(phasesData.map(phase => ({
          value: phase.activity_id,
          label: phase.activity_name || phase.activity_id,
          activityData: phase
        })));
      } catch (err) {
        notify.error(`Failed to load ticket phases: ${err.message}`);
        setPhases([]);
      } finally {
        setLoading(false);
      }
    };

    fetchPhases();
  }, [ticketType]); // ✅ Re-fetch when ticketType changes

  // ... SearchableSelect component
}

function TicketAutoFillFieldNameEntry(props) {
  const { field, getValue, setValue, id } = props;

  const config = get(field, ['ticketAutoFillLogic']) || {};
  const behavior = config.behavior;
  const ticketType = config.ticketType;
  const ticketPhase = config.ticketPhase;

  // ✅ Only show when behavior, ticketType, AND ticketPhase are all set
  // Three conditions that depend on three earlier entries in the same group
  if (behavior !== 'another_ticket' || !ticketType || !ticketPhase) {
    return null;
  }

  // ...component that fetches form fields for the selected phase
}
Enter fullscreen mode Exit fullscreen mode

How it works: Every entry always exists in the array returned by createTicketAutoFillEntries. But most components check conditions at the top and return null immediately if those conditions aren't met. The panel renders the entry slot but the component produces no output — React/Preact just renders nothing for that slot.

Why Pattern 3 is necessary here: The condition for TicketAutoFillTicketPhaseEntry depends on ticketType — a value set by TicketAutoFillTicketTypeEntry in the same group. When the user selects a ticket type, editField is called, the field schema updates, and getGroups is called again. But the component also needs to fetch phases from an API after that selection. The fetching happens inside the component, not in the factory. If Pattern 1 were used, you'd have to fetch data in the factory function — which runs synchronously and can't be async. Pattern 3 is the only approach that supports async data fetching inside conditional entries.


Choosing the Right Pattern

Here is a decision guide based on the three situations:

Q1: Do the entries completely change based on a mode/type selection?
    (e.g., 8 entries disappear and 6 different ones appear)

    YES → Pattern 1 (Build-time conditional)
    NO  → Continue to Q2

Q2: Does the condition depend on async data or on values
    set by other entries in the same group?

    YES → Pattern 3 (Component-level null return)
    NO  → Pattern 2 (Entry-level conditional in factory)
Enter fullscreen mode Exit fullscreen mode

In practice:

  • Use Pattern 1 for mode switches with large, completely different entry sets
  • Use Pattern 2 for feature flags that show/hide a handful of related entries
  • Use Pattern 3 for cascading configuration where each entry depends on the previous

The Tradeoffs

Pattern 1 Tradeoffs

Advantage: Only the entries that should exist are created. No unnecessary component mounting. The panel renders exactly what's needed.

Disadvantage: Entries are rebuilt on every getGroups call. Every time the designer changes any property, getGroups fires and the entire entries array is rebuilt from scratch. For a dropdown with 15 configuration entries, that's 15 object creations on every property change. This is not a performance problem in practice — object creation is fast — but it's worth knowing.

Disadvantage: Switching modes loses unsaved work. When the designer switches from Manual to API Options, the Manual-mode entries disappear from the array. Any unsaved configuration in those entries is gone (though the schema values persist). If they switch back to Manual, the entries re-appear and the values are still there in the schema — but the visual continuity is broken.

Watch out for: IDs that aren't unique. If dropdown-is-searchable appears in both the Manual set and the API Options set (it does — I put it in both), use different IDs or the same ID consistently. In my implementation I use the same ID for the searchable checkbox in all three modes, which means it's the same entry re-used — it stays mounted when switching modes, avoiding a re-mount flash.

Pattern 2 Tradeoffs

Advantage: Simple to reason about. The condition is in one place — the factory function. There's no logic spread across component bodies.

Advantage: Works well for feature flags. When a boolean flag toggles a small set of entries, Pattern 2 is the clearest expression of that logic.

Disadvantage: The full entries array is always built. Even the entries that won't be included are declared in the code flow — the if block is skipped but the factory function runs through all the code paths.

Disadvantage: Nested conditionals become complex. The grid field's validation rule entries show Pattern 2 applied to a list — each rule has entries that conditionally change based on the rule's type. This creates nested conditionals inside the factory function. It's readable for two levels; at three levels it becomes hard to follow.

Watch out for: The entries that always appear (like DynamicGrid and the validation rules header) must have their IDs outside any conditional block. If an always-visible entry ends up inside a conditional block by accident, it silently disappears for certain states.

Pattern 3 Tradeoffs

Advantage: Most flexible. Components can use React/Preact hooks, fetch async data, and check conditions that aren't knowable at build time.

Advantage: Cascading dependencies work naturally. Each component reads the current field state to decide whether to show. When earlier entries update the field state, later entries automatically re-evaluate their conditions.

Disadvantage: Layout jumps. When a component goes from returning null to returning actual UI, the panel layout shifts. The entries above and below the newly visible entry reflow. For long panels, this is noticeable. Patterns 1 and 2 avoid this because the entries are never in the array to begin with — there's nothing to appear, only different entries to replace others.

Disadvantage: isEdited is harder to implement. The isEdited function on the entry definition is called even when the component returns null. If isEdited returns true for a hidden entry, the blue dot appears even though nothing is visible. Make sure isEdited for Pattern 3 entries returns false when the entry would be hidden.

Disadvantage: All entries are always mounted as far as the panel is concerned. Even when a component returns null, React/Preact has still called the component function. Any hooks in the component body run even for the null path. This means useEffect in a component that checks its condition and returns null early still has its effects run. Structure your hooks carefully:

// ❌ Hooks before the null check — effects run even when hidden
function TicketAutoFillPhaseEntry(props) {
  const [phases, setPhases] = useState([]); // ← runs always

  useEffect(() => {
    fetchPhases(); // ← runs always, even when hidden
  }, []);

  const config = get(field, ['ticketAutoFillLogic']) || {};
  if (config.behavior !== 'another_ticket') return null;

  // ...
}

// ✅ Null check before hooks — hooks only run when visible
// But this violates React's rules of hooks!

// ✅ Correct: Use a wrapper component
function TicketAutoFillPhaseEntry(props) {
  const config = get(props.field, ['ticketAutoFillLogic']) || {};

  // ✅ Return null before any hooks
  if (config.behavior !== 'another_ticket' || !config.ticketType) {
    return null;
  }

  // All hooks below the null check — they only run when component is visible
  const [phases, setPhases] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    fetchPhases(config.ticketType).then(setPhases);
  }, [config.ticketType]);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Wait — this violates React's rules of hooks (hooks must not be called conditionally, after a return statement). The correct solution is to split into an outer wrapper that does the condition check and returns null, and an inner component that has all the hooks:

// ✅ Outer wrapper: condition check only, no hooks
function TicketAutoFillPhaseEntry(props) {
  const config = get(props.field, ['ticketAutoFillLogic']) || {};

  if (props.config.behavior !== 'another_ticket' || !props.config.ticketType) {
    return null;
  }

  // ✅ Delegate to inner component which can safely use hooks
  return TicketAutoFillPhaseEntryInner(props);
}

// ✅ Inner component: hooks always run (component is only mounted when visible)
function TicketAutoFillPhaseEntryInner(props) {
  const [phases, setPhases] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    fetchPhases(props.config.ticketType).then(setPhases);
  }, [props.config.ticketType]);

  return SearchableSelect({ /* ... */ });
}
Enter fullscreen mode Exit fullscreen mode

In practice, the Preact version of hooks (preact/hooks) is more permissive about hook ordering than React — it doesn't enforce the rules of hooks as strictly. But writing code that would break in React is a bad habit even when building Preact components.


Side by Side

Here is a summary to keep handy when choosing a pattern:

Pattern 1 (Build-time) Pattern 2 (Entry-level) Pattern 3 (Component null)
Where condition lives Factory function, before entries array Factory function, inside if block Inside each component
When evaluated Every getGroups call Every getGroups call On every component render
Supports async data No No Yes
Supports inter-entry dependencies No No Yes
Layout jumps on show No (entries swap) No (entries swap) Yes (entries appear)
Performance Best (only needed entries created) Good (conditional push) OK (all components mount)
Complexity Low Low-Medium Medium-High
Best for Mode switches Feature flags Cascading configuration

What Comes Next

Dynamic entry display solves the problem of showing the right controls. The next challenge is building controls that work well for large option lists — specifically a searchable select that works in Preact's properties panel context.

Article 12 covers building a full SearchableSelect component in Preact — keyboard navigation, click-outside detection, loading states, and why the properties panel context requires Preact rather than React, which changes everything about how you build interactive UI.


This is Part 11 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 properties-panel conditional-rendering form-editor javascript devex

Top comments (0)