DEV Community

Cover image for File Handling Across the Form Lifecycle: From Selection to Camunda Task Attachment
Sam Abaasi
Sam Abaasi

Posted on

File Handling Across the Form Lifecycle: From Selection to Camunda Task Attachment

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


Form-JS handles text, numbers, dates, selections, and booleans. All of these fit neatly into JSON — the format form data is stored and transmitted in. Files don't fit into JSON. A File object is a browser-specific binary object that cannot be serialized to JSON, stored in form state, or sent as part of a form submission payload.

Form-JS's built-in file picker stores file metadata (name, size, type) in form data but doesn't solve the core problem: getting the actual file bytes to a server when the form submits. That's your problem to solve.

I needed to solve it for a Camunda-integrated form where files selected by users needed to be uploaded as task attachments — associated with the active Camunda task through the REST API. The solution required three separate stages that work together across framework boundaries, DI container boundaries, and the browser's JavaScript sandbox.

This article documents all three stages, why each design decision was made, and how they connect into a complete system.


The Problem

When a user selects a file in a browser input, the browser gives you a File object. This object:

  • Lives in memory as long as the JavaScript reference to it exists
  • Cannot be serialized to JSON (JSON.stringify(file) returns "{}")
  • Cannot be stored in Form-JS's form state (which is a JSON-serializable object)
  • Cannot be passed through React or Preact Context across framework boundaries
  • Cannot survive a page navigation or form reset

Form-JS's form state looks like this:

{
  assignee: "john.doe",
  dueDate: "2024-01-15",
  priority: "high",
  attachment: null  // ← What do you put here?
}
Enter fullscreen mode Exit fullscreen mode

You can put the file's name in form state. You can put the file's size. But the actual bytes — the thing you need to upload — cannot go here. They have to live somewhere else while the form is being filled, then be retrieved and uploaded when the user submits.

The three questions this system must answer:

  1. Where do File objects live while the user is filling the form?
  2. How does the upload code find those File objects at submit time?
  3. How do files get from the browser to the Camunda server?

What I Tried First

Attempt 1: Store files in module-level variables

// ❌ Module-level storage
const pendingFiles = new Map();

export function storePendingFile(fieldKey, file) {
  pendingFiles.set(fieldKey, file);
}

export function getPendingFiles() {
  return pendingFiles;
}
Enter fullscreen mode Exit fullscreen mode

This works for one form. It breaks immediately when two forms exist on the same page — they share the same module-level Map. Form A's files are visible to Form B's upload code. If Form A is destroyed and Form B is submitted, Form A's files are uploaded as Form B's attachments.

Attempt 2: Store files in React Context

// ❌ React Context
const FileContext = React.createContext(new Map());

// In the file upload component:
const files = useContext(FileContext);
files.set(fieldKey, selectedFile);
Enter fullscreen mode Exit fullscreen mode

This works within a React component tree. It breaks when the CustomForm class — a plain TypeScript class, not a React component — needs to read the files at submit time. CustomForm has no access to React Context. The context doesn't cross the React/JavaScript boundary.

Attempt 3: Store files in form state as base64

// ❌ Base64 in form state
const base64 = await file.arrayBuffer()
  .then(buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))));

onChange({ value: base64 });
Enter fullscreen mode Exit fullscreen mode

This technically works but is destructive at scale. A 5MB file becomes a 6.7MB base64 string. Form-JS re-renders on every changed event. Storing a multi-megabyte string in form state makes the form unusably slow. For images and documents that users routinely attach to tasks, this approach fails completely.

The correct approach: The File objects live in local component state. A cross-boundary store attached to the event bus instance bridges them to the upload code. The form data carries only serializable metadata.


The Three-Stage System

Stage 1: SELECTION
  User selects file
  → File stored in component localFiles state
  → File name stored in form data (serializable)
  → File object registered in _pendingFilesRef Map

Stage 2: TRACKING
  _pendingFilesRef is attached to eventBus instance
  Accessible from: React components, Preact renderers, CustomForm class
  Key: fieldKey, Value: File[]

Stage 3: UPLOAD (on submit)
  CustomForm reads _pendingFilesRef
  For each field with pending files:
    POST to /engine-rest/task/{taskId}/attachment
    multipart/form-data: name, type, description, content
  Clear _pendingFilesRef after upload
Enter fullscreen mode Exit fullscreen mode

Stage 1: Local State in the Component

The file upload component is a React component (following the bridge pattern from Article 9). It maintains its own local state for selected files:

// FileUpload.tsx

interface LocalFile {
  file: File;
  id: number; // Unique ID for key prop
}

const FileUpload: React.FC<FileUploadProps> = ({
  field,
  inputId,
  readonly,
  disabled,
  value = [],
  onChange,
  onChangeValue,
  eventBus
}) => {
  // ✅ Stage 1: Files live in local component state
  // File objects are not serializable — they cannot go in form state
  const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
  const [toasts, setToasts] = useState<Toast[]>([]);

  const fileInputRef = useRef<HTMLInputElement>(null);
  const fieldKey = field.key || 'unknown_field';
Enter fullscreen mode Exit fullscreen mode

When the user selects files, they go into localFiles:

  const handleInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(event.target.files || []);
    if (files.length === 0) return;

    const validFiles: LocalFile[] = [];

    for (const file of files) {
      if (validateFile(file)) {
        validFiles.push({
          file,
          id: Math.floor(Math.random() * 1000000)
        });
      }
    }

    if (validFiles.length > 0) {
      // ✅ Files go into local state (not form state)
      setLocalFiles(prev => [...prev, ...validFiles]);

      // ✅ File NAMES go into form state (serializable)
      const allFiles = [...localFiles, ...validFiles];
      const fileNames = allFiles.map(lf => lf.file.name);
      onChange?.(fileNames);
      onChangeValue?.(fileNames);

      showToast(`${validFiles.length} file(s) added`, 'success');
    }

    event.target.value = '';
  }, [localFiles, validateFile, onChange, onChangeValue]);
Enter fullscreen mode Exit fullscreen mode

The split: File objects → localFiles state. File names → form data via onChange. The form data always has a human-readable list of selected file names. The actual file objects are held in memory by the component.

The validation that runs before accepting a file:

  const validateFile = useCallback((file: File): boolean => {
    const currentNumberOfFiles = localFiles.length;
    const currentTotalSize = localFiles.reduce((sum, lf) => sum + lf.file.size, 0);

    // Max file count
    if (currentNumberOfFiles + 1 > (field.validate?.maxFileNumber || Infinity)) {
      showToast(
        `${file.name}: Maximum ${field.validate?.maxFileNumber} files allowed`,
        'error'
      );
      return false;
    }

    // Max total size
    const maxTotalSizeBytes = (field.validate?.maxTotalSize || Infinity) * 1024 * 1024;
    if (currentTotalSize + file.size > maxTotalSizeBytes) {
      showToast(
        `${file.name}: Total size exceeds ${field.validate?.maxTotalSize} MB`,
        'error'
      );
      return false;
    }

    // Blacklist
    if (field.validate?.attachedValidationType === 'Blacklist' &&
        field.validate?.blacklistAttachment) {
      const blackList = field.validate.blacklistAttachment
        .trim().replaceAll(' ', '').split(',');
      const ext = file.name.split('.').pop()?.toLowerCase() || '';
      if (blackList.includes(ext)) {
        showToast(`${file.name}: '${ext}' format is not allowed`, 'error');
        return false;
      }
    }

    // Whitelist
    if (field.validate?.attachedValidationType === 'Whitelist' &&
        field.validate?.whitelistAttachment) {
      const whiteList = field.validate.whitelistAttachment
        .trim().replaceAll(' ', '').split(',');
      const ext = file.name.split('.').pop()?.toLowerCase() || '';
      if (!whiteList.includes(ext)) {
        showToast(
          `${file.name}: '${ext}' not allowed. Allowed: ${whiteList.join(', ')}`,
          'error'
        );
        return false;
      }
    }

    return true;
  }, [localFiles, field.validate, showToast]);
Enter fullscreen mode Exit fullscreen mode

Stage 2: The _pendingFilesRef Pattern

The bridge between the React component (Stage 1) and the upload code (Stage 3) is _pendingFilesRef — a { current: Map } object attached to the event bus instance.

Why the Event Bus Instance

The event bus instance is the one object that satisfies all requirements:

Requirement Event Bus Global Variable React Context DI Service
Accessible in React components ✅ (passed as prop via bridge)
Accessible in Preact renderers ✅ (passed as prop)
Accessible in CustomForm class ✅ (this.get('eventBus'))
Isolated per form instance ✅ (one bus per form) Depends
No framework dependency ✅ (plain JS object)

The event bus is already a plain JavaScript object. Attaching a property to it is attaching a property to a JavaScript object — no framework involvement, no serialization, no boundary restrictions.

Global variables fail the "isolated per form instance" requirement. React Context fails the "accessible in Preact renderers" and "accessible in CustomForm class" requirements. DI services fail the "accessible in React components" requirement — React components can't access the DI container, only props passed through the bridge.

The event bus instance is the only object that satisfies all requirements simultaneously.

Initialization in CustomForm

_pendingFilesRef is created in the CustomForm constructor — before any module initializes, before any renderer runs:

// CustomForm.ts

export class CustomForm extends Form {
  private _isRendered: boolean = false;

  constructor(options: FormOptions = {}) {
    const mergedOptions: FormOptions = {
      ...options,
      additionalModules: [
        // ... all modules
      ]
    };

    super(mergedOptions);

    // ✅ Attach _pendingFilesRef to the event bus instance
    // Done in constructor — before any renderer has a chance to run
    try {
      const eventBus = this.get('eventBus');
      if (eventBus && !eventBus._pendingFilesRef) {
        eventBus._pendingFilesRef = { current: new Map() };
        // Structure: Map<fieldKey: string, files: File[]>
      }
    } catch (e) {
      console.warn('⚠️ [CustomForm] Could not initialize _pendingFilesRef:', e);
    }
  }
Enter fullscreen mode Exit fullscreen mode

The { current: Map } wrapper (rather than the Map directly) mirrors React's useRef shape. This makes it clear that _pendingFilesRef holds a mutable reference and that _pendingFilesRef.current is the actual data. It also makes the TypeScript type clear:

// On the event bus:
eventBus._pendingFilesRef: { current: Map<string, File[]> }

// Accessed as:
eventBus._pendingFilesRef.current.get(fieldKey) // → File[]
eventBus._pendingFilesRef.current.set(fieldKey, files) // → void
Enter fullscreen mode Exit fullscreen mode

Writing to the Store From React

The FileUpload component syncs its local files to the store whenever localFiles changes:

  // ✅ Stage 2: Sync local files to the cross-boundary store
  useEffect(() => {
    if (eventBus?._pendingFilesRef) {
      const pendingFilesMap = eventBus._pendingFilesRef.current;
      const files = localFiles.map(lf => lf.file);

      if (files.length > 0) {
        pendingFilesMap.set(fieldKey, files);
      } else {
        // ✅ Clean up when all files are removed
        pendingFilesMap.delete(fieldKey);
      }
    }
  }, [localFiles, eventBus, fieldKey]);
Enter fullscreen mode Exit fullscreen mode

This useEffect runs every time localFiles changes — when files are added or removed. The Map is always current with the component's local state.

Removing Files

When a user removes a file, both the local state and the store are updated:

  const removeFile = useCallback((index: number) => {
    const newFiles = [...localFiles];
    const removedFile = newFiles[index];
    newFiles.splice(index, 1);

    // ✅ Update local state
    setLocalFiles(newFiles);

    // ✅ Update form data (file names list)
    const fileNames = newFiles.map(lf => lf.file.name);
    onChange?.(fileNames);
    onChangeValue?.(fileNames);

    showToast(`${removedFile.file.name} removed`, 'info');
    // The useEffect above will sync the Map automatically
  }, [localFiles, onChange, onChangeValue, showToast]);
Enter fullscreen mode Exit fullscreen mode

The Sequence Diagram

User            FileUpload         localFiles       _pendingFilesRef    Form Data
  |                |                   |                   |               |
  |--selects file->|                   |                   |               |
  |                |--validateFile()   |                   |               |
  |                |--setLocalFiles--->|                   |               |
  |                |<--useEffect fires |                   |               |
  |                |--Map.set(key,files)------------------>|               |
  |                |--onChange(names)---------------------------------------------->|
  |                |                   |                   |               |
  |--removes file->|                   |                   |               |
  |                |--setLocalFiles--->|                   |               |
  |                |<--useEffect fires |                   |               |
  |                |--Map.set(key,fewer files)------------>|               |
  |                |--onChange(fewer names)---------------------------------------->|
  |                |                   |                   |               |
  |--clicks Submit |                   |                   |               |
  |                |                   |          CustomForm reads Map      |
  |                |                   |          for each field with files:|
  |                |                   |          POST to Camunda API       |
  |                |                   |          Map.clear() after upload  |
Enter fullscreen mode Exit fullscreen mode

Stage 3: Upload on Submit

CustomForm handles the upload. It reads the _pendingFilesRef Map and uploads each file to Camunda's task attachment REST endpoint.

The Upload Method

// CustomForm.ts

  /**
   * Upload a file as a Camunda task attachment.
   * Called once per file after form submission.
   */
  async uploadFileAsAttachment(
    taskId: string,
    file: File,
    description = ''
  ) {
    // ✅ multipart/form-data — required by Camunda's attachment API
    const formData = new FormData();
    formData.append('attachmentName', file.name);
    formData.append('attachmentType', file.type || 'application/octet-stream');
    formData.append(
      'attachmentDescription',
      description || 'Uploaded via form'
    );
    formData.append('content', file); // ✅ The actual file bytes

    const response = await fetch(
      `/engine-rest/task/${taskId}/attachment`,
      {
        method: 'POST',
        body: formData,
        // ✅ No Content-Type header — fetch sets it automatically
        // for FormData with the correct multipart boundary
      }
    );

    if (!response.ok) {
      const text = await response.text();
      throw new Error(`Failed to upload attachment: ${text}`);
    }

    return await response.json();
  }
Enter fullscreen mode Exit fullscreen mode

The Content-Type header note is important. When using FormData with fetch, do NOT set Content-Type: multipart/form-data manually. If you set it manually, the multipart boundary — a unique string that separates file parts — won't be included. The server receives a malformed request and rejects it. Let fetch set the Content-Type automatically — it includes the boundary.

Uploading All Pending Files

The CustomForm exposes a method to upload all pending files for a given task:

  /**
   * Upload all files stored in _pendingFilesRef for the given task.
   * Call this after successful form submission.
   *
   * @param taskId - The Camunda task ID to attach files to
   * @returns Array of upload results
   */
  async uploadPendingFiles(taskId: string): Promise<any[]> {
    const eventBus = this.get('eventBus');

    if (!eventBus?._pendingFilesRef) {
      console.warn('[CustomForm] _pendingFilesRef not found');
      return [];
    }

    const pendingFilesMap = eventBus._pendingFilesRef.current;

    if (pendingFilesMap.size === 0) {
      return []; // No files to upload
    }

    const results: any[] = [];
    const uploadErrors: string[] = [];

    for (const [fieldKey, files] of pendingFilesMap.entries()) {
      for (const file of files) {
        try {
          const result = await this.uploadFileAsAttachment(
            taskId,
            file,
            `File field: ${fieldKey}`
          );
          results.push(result);
        } catch (err) {
          console.error(`[CustomForm] Failed to upload ${file.name}:`, err);
          uploadErrors.push(`${file.name}: ${(err as Error).message}`);
        }
      }
    }

    // ✅ Clear after upload — prevent double-upload on re-submit
    pendingFilesMap.clear();

    if (uploadErrors.length > 0) {
      throw new Error(
        `${uploadErrors.length} file(s) failed to upload: ${uploadErrors.join(', ')}`
      );
    }

    return results;
  }
Enter fullscreen mode Exit fullscreen mode

Integration With Form Submission

In the application code that hosts the form:

// ApplicationFormHandler.ts

async function handleFormSubmit(
  form: CustomForm,
  taskId: string,
  variables: Record<string, any>
) {
  // Step 1: Validate the form
  const errors = form.validate();
  if (Object.keys(errors).length > 0) {
    return; // Form has errors — don't submit
  }

  try {
    // Step 2: Complete the Camunda task with form variables
    await camundaClient.completeTask(taskId, { variables });

    // Step 3: Upload pending files as task attachments
    // Note: upload AFTER task completion — if task completion fails,
    // we don't want orphaned attachments
    const uploadResults = await form.uploadPendingFiles(taskId);

    if (uploadResults.length > 0) {
      console.log(`Uploaded ${uploadResults.length} attachment(s)`);
    }

    // Step 4: Handle success
    navigateToNextStep();

  } catch (err) {
    if (err.message.includes('failed to upload')) {
      // Task completed but files failed to upload
      showWarning('Form submitted but some files failed to upload. Please re-attach them.');
    } else {
      // Task completion failed
      showError('Submission failed. Please try again.');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Upload order matters. Upload files after task completion, not before. If you upload files before completing the task and the task completion fails (network error, validation failure), you have files attached to a task that wasn't actually completed. The user sees a success indicator on the file upload but the form is in a broken state. The reverse error — task completes but files fail to upload — is annoying but recoverable. The user can re-attach files manually.


The registerFileUploadHandler Pattern

CustomForm also provides a utility for connecting HTML file inputs that exist in the rendered form DOM to the upload handler. This is useful for simple file inputs that bypass the React component system:

  /**
   * Attach event listeners to file inputs with data-attach="true".
   * Call this after the form is rendered.
   *
   * @param taskId - The Camunda task ID to attach files to
   */
  registerFileUploadHandler(taskId: string) {
    // ✅ Find file inputs marked for direct upload
    const inputs = this._container?.querySelectorAll<HTMLInputElement>(
      'input[type="file"][data-attach="true"]'
    );

    if (!inputs?.length) return;

    inputs.forEach((input) => {
      input.addEventListener('change', async (event) => {
        const file = (event.target as HTMLInputElement).files?.[0];
        if (!file) return;

        try {
          await this.uploadFileAsAttachment(
            taskId,
            file,
            `File field: ${input.name}`
          );
          alert(`✅ File "${file.name}" uploaded successfully!`);
        } catch (err) {
          console.error('Attachment upload failed', err);
          alert(`❌ Upload failed: ${(err as Error).message}`);
        }
      });
    });
  }
Enter fullscreen mode Exit fullscreen mode

Usage in the application:

await form.importSchema(schema, data);

// Register after rendering — DOM exists now
form.registerFileUploadHandler(taskId);
Enter fullscreen mode Exit fullscreen mode

This pattern is for simple file inputs in custom HTML field renderers that don't use the React component system. The data-attach="true" attribute marks which inputs should trigger direct upload behavior.


The Complete FileUpload Component

For completeness, here is the full component including the drag-and-drop handler:

// FileUpload.tsx — complete component

export const FileUpload: React.FC<FileUploadProps> = ({
  field,
  inputId = 'attachmentId',
  readonly = false,
  disabled = false,
  value = [],
  onChange,
  onChangeValue,
  eventBus
}) => {
  const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
  const [toasts, setToasts] = useState<Toast[]>([]);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const fieldKey = field.key || 'unknown_field';

  // ✅ Stage 2: Sync to _pendingFilesRef whenever localFiles changes
  useEffect(() => {
    if (eventBus?._pendingFilesRef) {
      const pendingFilesMap = eventBus._pendingFilesRef.current;
      const files = localFiles.map(lf => lf.file);

      if (files.length > 0) {
        pendingFilesMap.set(fieldKey, files);
      } else {
        pendingFilesMap.delete(fieldKey);
      }
    }
  }, [localFiles, eventBus, fieldKey]);

  const showToast = useCallback((message: string, type: Toast['type']) => {
    const toast: Toast = {
      id: `toast-${Date.now()}-${Math.random()}`,
      message,
      type,
    };
    setToasts(prev => [...prev, toast]);
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== toast.id));
    }, 5000);
  }, []);

  // Validation, handleInput, handleDrop, removeFile
  // ... (shown in Stage 1 section above)

  const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
    if (disabled || readonly) return;
    event.preventDefault();
    event.stopPropagation();

    const files: File[] = [];
    if (event.dataTransfer.items) {
      Array.from(event.dataTransfer.items).forEach((item) => {
        if (item.kind === 'file') {
          const file = item.getAsFile();
          if (file) files.push(file);
        }
      });
    } else {
      files.push(...Array.from(event.dataTransfer.files));
    }

    if (files.length === 0) return;

    const validFiles: LocalFile[] = [];
    for (const file of files) {
      if (validateFile(file)) {
        validFiles.push({ file, id: Math.floor(Math.random() * 1000000) });
      }
    }

    if (validFiles.length > 0) {
      setLocalFiles(prev => [...prev, ...validFiles]);
      const allFiles = [...localFiles, ...validFiles];
      const fileNames = allFiles.map(lf => lf.file.name);
      onChange?.(fileNames);
      onChangeValue?.(fileNames);
      showToast(`${validFiles.length} file(s) added`, 'success');
    }
  }, [disabled, readonly, localFiles, validateFile, onChange, onChangeValue, showToast]);

  return (
    <div className={styles.fileUploadContainer}>
      <div
        className={styles.dropZone}
        onDrop={handleDrop}
        onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
      >
        <div className={styles.dropZoneBrowse}>
          <a className={styles.browseLink} onClick={() => fileInputRef.current?.click()}>
            {/* Upload icon SVG */}
            Browse file
          </a>

          <input
            ref={fileInputRef}
            id={inputId}
            type="file"
            onChange={handleInput}
            multiple
            disabled={disabled || readonly}
            className={styles.fileInput}
          />

          <div className={styles.dragDropText}>Drag and drop file here</div>
        </div>

        {/* File list */}
        <div className={styles.droppedList}>
          {localFiles.map((localFile, index) => (
            <div key={localFile.id} className={styles.attachedItem}>
              <div
                className={styles.image}
                onClick={() => {
                  // Create temporary URL for preview/download
                  const url = URL.createObjectURL(localFile.file);
                  const link = document.createElement('a');
                  link.href = url;
                  link.download = localFile.file.name;
                  link.click();
                  URL.revokeObjectURL(url);
                }}
              >
                {/* File type icon */}
              </div>
              <div className={styles.uploaded}>
                <div className={styles.fileName} title={localFile.file.name}>
                  {localFile.file.name}
                </div>
                <div>{(localFile.file.size / (1024 * 1024)).toFixed(2)} MB</div>
              </div>
              {!disabled && !readonly && (
                <button
                  type="button"
                  onClick={() => removeFile(index)}
                  className={styles.removeButton}
                  aria-label={`Remove ${localFile.file.name}`}
                >
                  {/* Delete icon */}
                </button>
              )}
            </div>
          ))}
        </div>
      </div>

      {/* Toast notifications */}
      {toasts.length > 0 && (
        <div className={styles.toastContainer}>
          {toasts.map((toast) => (
            <div
              key={toast.id}
              className={`${styles.toast} ${styles[toast.type]}`}
              onClick={() => setToasts(prev => prev.filter(t => t.id !== toast.id))}
            >
              <span>{toast.message}</span>
              <button onClick={() => setToasts(prev => prev.filter(t => t.id !== toast.id))}>
                
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Edge Cases

What if the user submits without selecting files? pendingFilesMap is empty. uploadPendingFiles finds nothing, returns an empty array, and returns without error. The form submits normally.

What if the component unmounts before submission? When a field is hidden or the form section changes, the React component may unmount. The localFiles state is lost — React clears it on unmount. But the _pendingFilesRef Map still holds the references from the last useEffect sync. The files are still accessible at upload time.

What if the user clears a file field after selecting? removeFile updates localFiles. The useEffect fires, calls pendingFilesMap.delete(fieldKey). The Map is updated. At upload time, that field has no pending files.

What about file size limits on the Camunda side? Camunda's task attachment API accepts files up to the server's configured limit (typically 10MB default). Client-side validation in validateFile enforces the limit configured in the field's validate.maxTotalSize property. The mismatch between client-side and server-side limits should be handled by showing a clear error message when the server rejects a file, which the uploadFileAsAttachment catch block handles.

What about concurrent uploads? uploadPendingFiles uses await in a for...of loop — uploads happen sequentially, not concurrently. Sequential upload is slower but easier to reason about for error handling. If one file fails, subsequent files still attempt to upload. For better performance with many files, you could use Promise.all() — but you lose sequential error recovery.


The Tradeoffs

_pendingFilesRef is imperative mutation, not declarative state. The Map is mutated directly — set, delete, clear — without going through React's state mechanism or Form-JS's state mechanism. This means React and Form-JS are not aware when the Map changes. They don't re-render. They don't fire events. If you need UI that reflects the Map's current state (a "files pending upload" counter in the form header, for example), you can't read it from the Map directly — you have to duplicate that state in the React component's localFiles state and read from there.

File objects don't survive form reset. If the user clicks a "Reset" button on the form, Form-JS clears form data. The file names in form data are cleared. But the React component's localFiles state is managed by React — Form-JS's reset doesn't touch it. After a reset, the UI shows no files selected (if the component re-renders from empty form data) but _pendingFilesRef might still hold references from before the reset. The fix: listen to form reset events and clear both local state and the Map:

useEffect(() => {
  const handleReset = () => {
    setLocalFiles([]);
    if (eventBus?._pendingFilesRef) {
      eventBus._pendingFilesRef.current.delete(fieldKey);
    }
  };

  eventBus?.on('form.reset', handleReset);
  return () => eventBus?.off('form.reset', handleReset);
}, [eventBus, fieldKey]);
Enter fullscreen mode Exit fullscreen mode

Upload happens outside the form submit lifecycle. Form-JS's form.submit() event and the submitForm() handler don't know about file uploads. uploadPendingFiles is a manual call by the application code after the Camunda task is completed. If the application code forgets to call it, files are silently not uploaded. A more integrated approach would override form.submit() to include file upload in the submission flow — but this creates coupling between the generic form and the Camunda-specific upload logic.


What Comes Next

The file handling system completes the cross-cutting concerns of the Form-JS extension system. The remaining articles cover the architecture pieces that make everything work together: the Form Logics group that organizes properties panel entries, the subclassed Form and FormEditor that bootstrap all modules, and the capstone architecture overview.

Article 19 covers the Form Logics panel group — how seven independent providers contribute to the same panel section without knowing about each other, and the create-if-not-exists pattern that makes cooperative group ownership work.


This is Part 18 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 file-upload react lifecycle javascript devex

Top comments (0)