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:
- The control is controlled. It renders
valueand reports edits throughonChange. 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. - 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. - The control reaches the platform only through
context. That is the sameParswareRuntimesurface an app gets —context.datafor reads,context.logfor logging. No direct HTTP calls. - 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:
{
"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
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
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.
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 writes | Substitutes for | Bind it to |
|---|---|---|
| A short string | Text | a text field |
| A long string (data URL, JSON, formatted text) | MultilineText | a multi-line text field |
| A number | Number | an integer or decimal field |
| A boolean | Boolean | a boolean field |
| An ISO date/time string | DateTime | a date/time field |
| A record id | Lookup | a lookup field |
| An Object Storage key | File, Image | a 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
valueprop 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.
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: trueonreact/react-domandresolve.dedupeinvite.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
valueprop. 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
exposeskey exactly, including the leading./(./Control).
Related scenarios
- Build a Custom React App.
- Use the Runtime SDK.
- Register a Custom Control.
- Bind a Custom Control to a field.