Skip to main content

Build a Custom Control

Goal​

Write a Custom Control — a reusable React component that replaces the built-in editor for a field on a Model-Driven form — from an empty folder to a control a maker can bind to a field.

A Custom Control is the right tool when a field needs an editor the platform does not ship: a signature pad, a colour picker, a star rating, a map, a barcode scanner. It is not the tool for a whole screen — that is a Custom React App.

Prerequisites​

  • Node.js 18 or later and npm.
  • Familiarity with React 18 and TypeScript.
  • A publisher and a solution in the environment the control will be installed into. The publisher's prefix (for example par) is part of the control's logical name.
  • Having read Build a Custom React App helps: a control is hosted the same way an app is, and only the contract differs.

The control contract​

A Custom Control is a single React component whose props are fixed by the platform. The Model-Driven App Player renders it in place of the native field editor and supplies all four props:

interface ParswareControlProps {
/** The bound field's current value. Its type follows the field's data type. */
value: unknown;
/** Commit a new value back to the form. */
onChange: (next: unknown) => void;
/** The bound field's metadata — type, label, required flag, constraints. */
fieldMetadata: FieldMetadata;
/** The same auth/data/logging context a Custom React App receives. */
context: RuntimeContext;
}

Four rules follow from that shape, and a control that breaks any of them is incomplete:

  1. The control is controlled. It renders value and reports edits through onChange. It does not hold the committed value in its own state as the source of truth, and it does not save the record — the form owns saving.
  2. The control reads its field's metadata rather than assuming it. The label, the required flag, the maximum length, a lookup's target entity, and an option set's name all arrive in fieldMetadata. A control that hard-codes a label is a control that can be used exactly once.
  3. The control reaches the platform only through context. That is the same ParswareRuntime surface an app gets — context.data for reads, context.log for logging. No direct HTTP calls.
  4. The control degrades rather than throws. It is mounted inside somebody else's form; an exception takes the whole record with it. Guard for a missing browser API, an unexpected value type, and an empty value.

Steps​

1. Create the project​

The project is set up exactly as a Custom React App's — package.json, tsconfig.json, index.html, src/vite-env.d.ts, vite.config.ts — see step 1 of Build a Custom React App. The Runtime SDK and the packer are downloads on this help site, installed by address:

package.json (excerpt)
{
"name": "signature-pad",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run",
"pack": "node node_modules/@parsware/pack/src/cli.mjs"
},
"dependencies": {
"@parsware/runtime-sdk": "https://docs.parsware.com/sdk/parsware-runtime-sdk-1.0.0.tgz",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@parsware/pack": "https://docs.parsware.com/sdk/parsware-pack-1.0.0.tgz"
}
}

The pack script is what step 8 runs. Declaring it here means the packer is invoked the same way in every project and on every machine — npm run pack — rather than depending on where the tool happens to be installed. It calls the packer's file rather than its parsware-pack bin shim, because running a shim is an exec, and an exec can be refused for reasons that have nothing to do with your project — most often macOS Gatekeeper, which quarantines files extracted from a downloaded archive and then reports bad interpreter: Operation not permitted.

2. Configure Module Federation​

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

// The control reuses the Shell's React and Runtime SDK instances; its bundle ships
// no copy of either. Without `singleton: true`, a second React would break hooks.
const sharedSingletons = {
react: { singleton: true, requiredVersion: '^18.3.1' },
'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
'@parsware/runtime-sdk': { singleton: true },
};

export default defineConfig({
plugins: [
react(),
federation({
name: 'par_signature_pad', // the container's name: unique per environment
filename: 'remoteEntry.js',
exposes: { './Control': './src/Control.tsx' },
shared: sharedSingletons,
manifest: true, // writes dist/mf-manifest.json, which the packer reads
}),
],
build: { target: 'esnext' },
});

By convention a control exposes ./Control where an app exposes ./App. Nobody types it anywhere: the packer reads it out of mf-manifest.json, which is why manifest: true is not optional.

3. Write the control​

src/Control.tsx
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import type { ParswareControlProps } from '@parsware/runtime-sdk';
import './index.css';

/**
* A signature pad bound to a text field. The committed value is a PNG data URL, so
* the field it binds to is an ordinary text field — the control decides what the
* text means, the platform stores it unchanged.
*/
export default function Control({ value, onChange, fieldMetadata, context }: ParswareControlProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const drawing = useRef(false);
const [hasInk, setHasInk] = useState(false);

// 1. Announce the mount through the platform's log — a control reaches platform
// services only through `context`.
useEffect(() => {
context.log('debug', 'Signature pad mounted', { field: fieldMetadata.logicalName });
}, [context, fieldMetadata.logicalName]);

// 2. Begin a stroke. A missing 2D context is a no-op rather than a throw, so the
// control degrades on a canvas-less environment instead of breaking the form.
const startStroke = (e: PointerEvent<HTMLCanvasElement>) => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
drawing.current = true;
const rect = e.currentTarget.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
};

// 3. Commit the drawing to the form when the stroke ends. `onChange` is the only
// way a value reaches the record — the control never saves anything itself.
const endStroke = () => {
if (!drawing.current) return;
drawing.current = false;
const dataUrl = canvasRef.current?.toDataURL('image/png');
if (dataUrl) onChange(dataUrl);
};

// 4. Clearing is an edit like any other: wipe the canvas, commit an empty value.
const clear = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasInk(false);
onChange('');
};

const signed = typeof value === 'string' && value.length > 0;

return (
<div className="signature-pad">
<canvas
ref={canvasRef}
width={320}
height={120}
role="img"
aria-label={`${fieldMetadata.displayName} signature canvas`}
onPointerDown={startStroke}
onPointerUp={endStroke}
onPointerLeave={endStroke}
/>
<div className="signature-pad__row">
<span>
{signed || hasInk ? 'Signed' : 'Not signed'}
{/* The required marker comes from metadata, not from a hard-coded assumption. */}
{fieldMetadata.isRequired && !signed && !hasInk ? ' — required' : ''}
</span>
<button type="button" onClick={clear}>Clear</button>
</div>
{/* Re-opening a saved record must show the stored signature. */}
{signed && <img src={value as string} alt={`${fieldMetadata.displayName} signature`} />}
</div>
);
}

4. Decide which field types the control substitutes for​

At registration a control declares Substitutes for — the field data-type families it can replace (Text, MultilineText, Number, Boolean, DateTime, Lookup, File, Image, Custom). The form designer offers the control only for fields whose control type is on that list, so a date picker is never offered for a boolean.

These are not the built-in control names

A form placement picks a built-in control — TextArea, Toggle, Dropdown, Date, Time. substitutesFor uses a different, shorter vocabulary: the family a field's data type belongs to. The two share four names (Text, DateTime, Lookup, Custom), which is what makes them easy to confuse. Declaring TextArea is rejected at registration with /substitutesFor/1 enum.

Choose the list from what the control actually reads and writes:

The control writesSubstitutes forBind it to
A short stringTexta text field
A long string (data URL, JSON, formatted text)MultilineTexta multi-line text field
A numberNumberan integer or decimal field
A booleanBooleana boolean field
An ISO date/time stringDateTimea date/time field
A record idLookupa lookup field
An Object Storage keyFile, Imagea file or image field

An option-set field has no family of its own: it is derived as Text, so a control for one declares Text (or Custom).

Declaring a type the control cannot actually handle is the most common cause of a control that "works in development and breaks on a real form" — the designer will offer it for a field whose value shape it never expected.

5. Handle every value state​

value arrives in four states and each needs a deliberate answer:

  • Empty (undefined, null, or '') — a new record, or a cleared field. Render the empty editor, not a broken one.
  • A stored value — an existing record being re-opened. Render it; a control that only shows what the user typed this session silently hides saved data.
  • A value of an unexpected type — the control was bound to a field type it does not handle. Render a neutral fallback rather than throwing.
  • A value changed from outside — the form may reset or reload. React to the value prop rather than caching the committed value in state.

6. Test the control​

Render the component directly with a mock runtime and assert on the contract: what it renders for a given value, and what it passes to onChange.

src/Control.test.tsx
import { expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ParswareRuntime } from '@parsware/runtime-sdk';
import Control from './Control';

// Only what the control calls needs to be real.
const mockRuntime = () => ({ log: () => {} }) as unknown as ParswareRuntime;

const fieldMetadata = {
logicalName: 'par_signature',
displayName: 'Signature',
dataType: 0, // Text
isRequired: true,
} as never;

it('renders a stored signature when the record already has one', () => {
render(
<Control
value="data:image/png;base64,AAA"
onChange={() => {}}
fieldMetadata={fieldMetadata}
context={mockRuntime()}
/>,
);

expect(screen.getByAltText('Signature signature')).toBeInTheDocument();
});

it('commits an empty value when cleared', async () => {
const onChange = vi.fn();
render(<Control value="data:image/png;base64,AAA" onChange={onChange} fieldMetadata={fieldMetadata} context={mockRuntime()} />);

await userEvent.click(screen.getByRole('button', { name: 'Clear' }));

expect(onChange).toHaveBeenCalledWith('');
});

Test the required-but-empty case too: the marker a required field shows is part of what the control promises the form. The test setup — vitest, jsdom, Testing Library and a vitest.config.ts that leaves Module Federation out — is the app's, in step 6 of Build a Custom React App; add @testing-library/user-event for the click.

7. Declare what the bundler cannot know​

Add a parsware block to the control's package.json. It holds exactly the three things the build has no way to work out — the publisher-prefixed logical name the control installs under, the name shown in the field palette, and the field data-type families it can stand in for:

{
"version": "1.0.0",
"parsware": {
"logicalName": "par_signature_pad",
"displayName": "Signature Pad",
"description": "Capture a hand-drawn signature on a text field.",
"substitutesFor": ["Text", "Custom"]
}
}

The Module Federation container name, the exposed module and the entry file are deliberately not here. They are read out of the build's own mf-manifest.json (the manifest: true of step 2), so they cannot drift from vite.config.ts.

version is the control's own npm version. It is what a maker sees in the Custom Controls grid, so npm version patch before packing is how a build becomes identifiable.

8. Pack it​

npm run pack # builds, then writes package/<logicalName>-<version>.zip

The archive holds the build output plus a parsware.json composed from the two sources above. That single file is the whole registration: a maker uploads it in the Admin Control Center and fills in nothing else.

npm run pack -- --no-build # package an existing build
npm run pack -- --module ./Grid # when the remote exposes more than one module

The -- is npm's, not the packer's: it is what stops npm swallowing the flag and passes it through to the script.

The packer refuses anything the platform would refuse later — a missing parsware block, a logical name that could never pass a prefix check, a built-in control name where a data-type family belongs — so the mistake is named in your terminal rather than discovered as a rejected upload.

The stored package is served from the platform's own origin, so the solution carries the control's code: exporting it and importing it into Test installs a working control, not a pointer to your machine. There is no URL to allow-list and none to go stale.

Hot reload while you are writing it​

Packing for every change is a slow loop. In a development environment you can register the control against its running dev server instead: in the solution, Custom Controls, New, Use a running dev server, and type the address npm run dev prints. The platform reads the control's name from your package.json and its scope, module and entry file from the mf-manifest.json the federation plugin serves when manifest: true is set, so there is nothing else to fill in. If the control is already installed, remove it first; when you are done, upload the package, which replaces the dev-server registration.

From then on a form loads the control straight from your machine: save a change and refresh the form to see it, with no build, no pack and no upload. The change does not swap in while the form stays open: fast refresh in your dev server cannot reach the Shell's React, so the in-place hot reload you get on the control's own development page stops at the Shell.

Two things have to allow it. The Shell loads code only from origins on its allow-list (REMOTE_ALLOWED_ORIGINS for a deployment, VITE_REMOTE_ALLOWED_ORIGINS for a Shell run with npm run dev), so your dev server's origin must be on it. And your dev server must accept requests from the platform's address, which Vite does for localhost by default and otherwise needs server.cors. The platform refuses a dev server in Test and Production: a control registered that way has no package, so its code would not travel with the solution, which is the whole problem packaging exists to solve.

A maker then registers the control (Register a Custom Control) and binds it to a field in the form designer (Bind a Custom Control to a field). The manifest that registration produces is specified in Custom Control manifest.

What happens behind the scenes​

When the Model-Driven App Player renders a form, it reads each field's binding. A field bound to a Custom Control resolves to that control's registration in the current environment, and the Player loads the control's Module Federation remote — sharing the Shell's React and Runtime SDK instances, exactly as it does for a Custom React App — and mounts the exposed component in place of the built-in editor, passing the field's current value, an onChange wired into the form's edit buffer, the field's metadata, and the same runtime an app gets, whose calls go out as the person using the form. Values the control commits become part of the form's pending edits and are written when the record is saved, so the control participates in validation and saving like any native editor.

Troubleshooting​

  • The control does not appear in the form designer's control list — the field's control type is not on the control's Substitutes for list, or the control is registered in a different environment than the one being designed in.
  • "Invalid hook call" when the control mounts — two React copies. Check singleton: true on react/react-dom and resolve.dedupe in vite.config.ts.
  • Edits disappear when the record is saved — the control never called onChange, or called it with a value the field's data type cannot store. The form saves what it was told, not what the control is displaying.
  • The control shows an empty editor on a record that has a value — the control is rendering its own state instead of the value prop. The control must be controlled.
  • The whole form fails to render — the control threw during render. A control is mounted inside somebody else's page; guard for a missing browser API and an unexpected value type instead of assuming.
  • The exposed module cannot be found — the registered module must match the exposes key exactly, including the leading ./ (./Control).
  • Build a Custom React App.
  • Use the Runtime SDK.
  • Register a Custom Control.
  • Bind a Custom Control to a field.