@dmytromykhailiuk/preact-signal-formly
v1.0.0
Published
Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and preact-signal-hook-forms. Signal-first, zero re-render.
Downloads
560
Maintainers
Readme
@dmytromykhailiuk/preact-signal-formly
Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and @dmytromykhailiuk/preact-signal-hook-forms.
Full documentation: open Docs in a browser — every option, with examples, a table of contents and cross-links. This README is the short form.
Signal-first, zero re-render. Field components mount once; every update — values, dynamic
props, validation messages, visibility — flows through signals bound directly to DOM attributes
and text content. Iteration and conditional display use For/Show from @preact/signals/utils
instead of re-rendering conditions and loops.
Install
npm i @dmytromykhailiuk/preact-signal-formly @dmytromykhailiuk/preact-signal-hook-forms @preact/signals preactRequires
@preact/signals^2.0.0 (theFor/Showutilities live in the/utilssubpath, which is a signals v2 feature).
Quick start
import { signal } from "@preact/signals";
import {
createFormlyFormBuilder,
defineFields,
} from "@dmytromykhailiuk/preact-signal-formly";
// 1. Build once (module scope) — register anything you need, then build().
const FormlyForm = createFormlyFormBuilder().build<{
email: string;
bio: string;
}>();
// 2. Three writable signals: model, config, formState.
const model = signal({ email: "", bio: "" });
const formState = signal(undefined);
const config = signal(
defineFields([
{ key: "email", type: "input", props: { label: "Email", required: true } },
{ key: "bio", type: "textarea", props: { label: "Bio" } },
])
);
export function App() {
return (
<FormlyForm
model={model}
config={config}
formState={formState}
onSubmit={console.log}
>
<button type="submit">Send</button>
</FormlyForm>
);
}modelis two-way synced with the form values: typing updatesmodel.value; writing a new object tomodel.valueupdates the inputs. Treat the value as immutable — always write a new object. A deep-equal write is a no-op; a different value goes throughsetValue, which dirties the form (usecontrolRef+control.reset()to set a new pristine baseline).formStateis two-way synced withform.formState.sharedfrom the base library — a writable scratch signal for cross-field/app state (both sides always hold the same reference).configis read reactively. Replace it with a new array and mounted fields update in place through signals (matched bykey), without remounting.defaultValueon a field config applies when the model holds no value at that path (the model always wins). It is registered as the control's default for that path, so the field starts pristine andcontrol.reset()restores it — including for fields that appear in the config later. It works on every kind of field: a scalar on a leaf, a whole object on a group, the initial rows on an array. Values are deep-cloned, so the form can never mutate your config object. An array field whosedefaultValueis not an array throws, naming the path.formOptionsis forwarded to the base library'suseForm, read once at mount — most notablymode/reValidateMode, which any field can override. See When validation runs.
The signal rules
The whole API hands you signals and callbacks — never changing plain values:
- Never read
signal.valuein a component body — that subscribes the component and causes re-renders. Unwrap only insidecomputed/useComputed/effect/useSignalEffect. - Pass signals directly to DOM attributes and text content:
disabled={disabledComputed},<span>{label}</span>. - Use
<For each={signal}>for lists and<Show when={signal}>for conditionals (both from@preact/signals/utils).
Field config
interface FormlyFieldConfig {
key?: string | number; // path segment relative to the parent
type?: string; // registered type name
props?: { label, placeholder, description, disabled, required,
min, max, minLength, maxLength, pattern, options, type, ... };
defaultValue?: any; // applied when the model has no value at this path
className?: string;
wrappers?: string[]; // overrides the type's default wrappers; [0] is outermost
hide?: boolean | Signal<boolean> | ((ctx) => boolean) | string; // string = expression
expressions?: { // dynamic overrides, evaluated in computeds
"hide"?: ((ctx) => boolean) | string,
"className"?: value | Signal | ((ctx) => value) | { $expr: string },
"props.<name>"?: value | Signal | ((ctx) => value) | { $expr: string },
};
validators?: {
validation?: string[]; // names registered via registerValidator
[name: string]: fn | { expression: fn; message?: string | fn };
};
validation?: {
messages?: Record<string, string | ((error, field) => string)>;
mode?: ValidationMode; // overrides formOptions.mode for this field
reValidateMode?: ReValidateMode;
};
fieldGroup?: FormlyFieldConfig[]; // nested group (keyless = transparent)
fieldGroupClassName?: string;
fieldArray?: FormlyFieldConfig | ((index: number) => FormlyFieldConfig);
hooks?: { onInit?, onDestroy? };
}Expression callbacks receive { model, formState, field, control, namePath } — model and
formState are signals; reading them inside the expression makes it reactive (expressions are
evaluated inside computeds, so no component re-renders).
The three keys are "hide", "className" and "props.<name>". Anything else is a mistake:
it is reported once through console.error, naming the field, rather than being silently
ignored. TypeScript rejects a wrong prefix ("prop.disabled") outright; a wrong prop name
("props.disbaled") can only be caught at runtime, and is — which matters most for configs
parsed from JSON, where there are no types at all.
expressions["className"] overrides config.className. Field types and wrappers read the
resolved value as ctx.className (a signal), not from config.className.
String expressions (JSON configs)
A config that arrives from a backend is JSON, and JSON cannot carry a callback. So an expression may also be written as a string:
{
"key": "city",
"type": "input",
"hide": "!model.value.address.country",
"expressions": {
"props.disabled": { "$expr": "!model.value.address.country" },
"props.placeholder": {
"$expr": "model.value.address.country ? 'Enter a city' : 'Pick a country'"
}
}
}hide accepts a bare string — its target is a boolean, so a string there could never have
been a static value. Everywhere else an expression must be wrapped in { "$expr": "..." }, so
that a plain string stays a plain string ("props.label": "Name" keeps working).
The string is parsed and interpreted, never compiled — no eval, no new Function. This
is not only a CSP question: a config from a backend is untrusted input, and compiling it would
be remote code execution in the user's browser. The grammar is the security boundary:
- read from
model,formState,field,namePath— and nothing else; there is no way to namewindow,fetchorconstructor; - member access and indexing:
model.value.items[0].id(a missing branch yieldsundefinedrather than throwing, so?.is never required); - literals,
!,- + * / %,=== !== == != < > <= >=,&& || ??, anda ? b : c; - calls to a fixed list of pure, non-mutating methods —
includes,startsWith,endsWith,indexOf,lastIndexOf,slice,toLowerCase,toUpperCase,trim,charAt,at,spliton strings;includes,indexOf,lastIndexOf,slice,join,aton arrays. Anything that can amplify its input (repeat,padStart) is deliberately excluded.
There is no assignment, no new, and no way to call an arbitrary function — including one
that happens to live in your own model. A malformed expression throws, naming the source;
parsed expressions are cached, so a string is parsed once however often it is evaluated.
props.required/min/max/minLength/maxLength/pattern map to the base library's built-in rules.
Builder API
const builder = createFormlyFormBuilder({ builtIns: true })
.registerType(name, component, { wrappers, defaultProps, extends })
.registerArrayType(name, component)
.registerWrapper(name, component)
.registerLazyType(name, loader, { wrappers, defaultProps, extends, errorFallback })
.registerLazyArrayType(name, loader, { errorFallback })
.registerLazyWrapper(name, loader, { errorFallback })
.registerValidator(name, fn, defaultMessage?)
.registerValidationMessage(errorType, message)
.registerExtension(name, { prePopulate, onPopulate, postPopulate });
const FormlyForm = builder.build<Model>();build()snapshots the registry — registrations made afterwards don't affect already-built components. Callbuild()again to pick them up.- Re-registering a name overrides it (including built-ins).
{ builtIns: false }starts headless. - Built-ins: types
input,textarea,select,checkbox,radio; wrapper"field"(label + description + error); default messages for the built-in rules. extendsinheritswrappersanddefaultPropsfrom another registered type. The component is not inherited —registerTypealways takes its own. A circular chain throws.- Extensions mutate the config draft during resolution, in the order
prePopulate→ type defaults merge →onPopulate→postPopulate. The draft is a private clone, so the config object you passed is never touched. hooks.onInitruns once after a field mounts and may return a cleanup function;hooks.onDestroyruns on unmount, including when the field is removed from the config.- The
registerLazy*methods take a loader instead of a component — see below.
Lazy types & wrappers
A registry usually outlives any single form: a rich text editor, a date picker, a map picker
are all registered up front, and most forms use none of them. The registerLazy* methods take
a loader — a function returning a dynamic import() — so the component travels in its own
chunk, fetched only when a field that uses it first renders.
const builder = createFormlyFormBuilder()
.registerLazyType("rating", () => import("./types/Rating"), {
wrappers: ["field"],
defaultProps: { max: 5 },
})
.registerLazyArrayType("phones", () => import("./types/PhonesArray"))
.registerLazyWrapper("card", () => import("./wrappers/Card"));Everything else is unchanged: the name is used in configs exactly as an eagerly registered one
({ key: "score", type: "rating" }), and re-registering a name still overrides it — including
replacing an eager registration with a lazy one, or a built-in with a lazy override.
The loader may resolve to the component itself or to a module whose default export is the
component, so () => import("./Rating") works as is. For a named export, map it in the loader:
.registerLazyType("stars", () => import("./types/Stars").then((m) => m.StarsType))It is called at most once per registration, however many fields, array rows or built forms use the name — the result is memoised and shared.
While the chunk is in flight, the slot renders nothing. There is no fallback or spinner:
the field itself is already live. Its defaultValue is seeded and its validation rules are
attached when the field mounts, not when the component arrives, so the model is correct and
the form validates while the import is still on the wire.
Registration options are eager — only the component is lazy. wrappers, defaultProps and
extends are plain data read while the config resolves, so they behave exactly as on
registerType. One visible consequence: a lazy type's wrappers render immediately, so the
built-in "field" wrapper shows its label and error message around the still-empty slot.
A lazy wrapper owns its children, so a field wrapped in a wrapper that is still loading
renders nothing until it lands. Wrappers nest as always — wrappers[0] stays outermost whether
the wrappers are eager, lazy or a mix.
If a chunk fails to load — a network blip, a stale hashed filename after a deploy — the form
does not crash. The failure is reported through console.error, the optional errorFallback
renders in the slot (without it the slot just stays empty), and the import is retried the next
time a field using that name mounts:
.registerLazyWrapper("card", () => import("./wrappers/Card"), {
errorFallback: (error) => <p class="load-error">Could not load this field: {String(error)}</p>,
})Zero re-render still holds. A chunk arriving is a signal flip consumed by <Show> — not
component state, not Suspense. The loaded component mounts once and never re-renders, and
nothing around it re-renders either: not the form, not the field, not its wrappers, not the
sibling fields.
Typing. registerLazyType widens the builder's type map just like registerType, so
defineFields<BuilderTypes<typeof builder>> keeps narrowing props by type name. Where props
cannot be inferred through the loader's promise, name them:
.registerLazyType<"rating", RatingProps>("rating", () => import("./types/Rating"))Writing a custom type
A type gets control and namePath and binds its input itself, exactly as it would with
@dmytromykhailiuk/preact-signal-hook-forms outside of formly.
Uncontrolled — spread control.register(namePath) onto the element:
import { useComputed } from "@preact/signals";
import { createFieldType } from "@dmytromykhailiuk/preact-signal-formly";
const Text = createFieldType<{ placeholder?: string }>(
({ control, namePath, props, errorMessage, id }) => {
// Renders exactly once. All dynamism = signals.
const placeholder = useComputed(() => props.value.placeholder ?? "");
return (
<div>
<input
{...control.register(namePath)}
id={id}
placeholder={placeholder}
/>
<span role="alert">{errorMessage}</span>
</div>
);
}
);Controlled — for widgets with no DOM input of their own, use <Controller> or
useController from the base library:
import { Controller } from "@dmytromykhailiuk/preact-signal-hook-forms";
const Rating = createFieldType<{ max?: number }>(
({ control, namePath, props, id }) => {
const max = useComputed(() => props.value.max ?? 5);
return (
<Controller
control={control}
name={namePath}
// field.value is a signal; field.onChange/onBlur are callbacks
render={({ field }) => (
<div id={id}>
<span>{field.value}</span> / <span>{max}</span>
<button
type="button"
onClick={() => field.onChange((field.value.peek() ?? 0) + 1)}
>
+
</button>
</div>
)}
/>
);
}
);
builder.registerType("rating", Rating, { wrappers: ["field"] });control.register(namePath) carries the rules derived from the field config (required,
pattern, validators, …); options you pass explicitly win over them. <Controller> and
useController leave the rules alone, so they keep working too — and a type that binds
nothing at all still validates, because the rules live on the field node.
The context contains: control (the FormControl, scoped to this field), namePath
(the field's dot-path, e.g. "items.0.name" — the field's name), formState (the shared
signal), config (readonly signal of the resolved field config), plus props, className
(both signals, with their expressions applied), fieldState, errorMessage, id. Wrappers
get the same plus children (createWrapper). Array types get array
(append/remove/move/…) and renderItems() (createArrayType).
Validation
Three layers, all reported per field as signals with resolved messages:
- Built-in rules from
props:required,min,max,minLength,maxLength,pattern. - Registered validators:
registerValidator("email", fn, "Invalid email"), used viavalidators: { validation: ["email"] }. - Inline validators:
validators: { myCheck: (value, model, field) => boolean | string }or{ expression, message }. Returntrue/undefined= valid,false= invalid (message resolved from the registry), a string = inline error message. Async validators are supported and receive anAbortSignal.
Message precedence: field validation.messages → inline entry message → registry message →
validator default → validator's returned string → error type. Messages may be functions
(error, field) => string.
When validation runs
mode decides the behaviour before the first submit; reValidateMode takes over once the form
has been submitted or while the field is showing an error.
| mode | before the first submit |
| ------------------- | ------------------------------------ |
| "all" (default) | on change and on blur |
| "onChange" | on every change |
| "onBlur" | on blur |
| "onTouched" | first on blur, then on every change |
| "onSubmit" | never — only on submit / trigger() |
reValidateMode is "onChange" (default), "onBlur" or "onSubmit".
The default is "all" — a config-driven form is usually a long one, and telling someone at
submit time about a field they filled in ten fields ago is the worse default. Note that this
differs from the base library, which defaults to "onSubmit". To get that behaviour back:
<FormlyForm … formOptions={{ mode: "onSubmit" }} />Set it for the whole form through formOptions, and override it per field through
validation in the field config — a field may be stricter or looser than its form:
<FormlyForm
model={model}
config={config}
formState={formState}
formOptions={{ mode: "onBlur", reValidateMode: "onChange" }}
/>defineFields([
// validates on every keystroke, though the form is "onBlur"
{ key: "slug", type: "input", validation: { mode: "onChange" } },
// stays quiet until submit, though the form is not
{ key: "notes", type: "textarea", validation: { mode: "onSubmit" } },
]);formOptions is forwarded to the base library's useForm and read once at mount, so it also
carries resolver (zod/yup schemas), criteriaMode, delayError and shouldFocusError.
defaultValues is not accepted — the model prop and each field's defaultValue own that.
Explicit validation — trigger(), handleSubmit(), setValue({ shouldValidate: true }) — is
unaffected by any of this and always runs.
Groups & arrays
- Groups:
fieldGroupnests fields; akeyprefixes child paths (address.city), a keyless group is purely visual. - Arrays:
fieldArrayis the item template (or a factory receiving the index). Item paths are indexed automatically (phones.0.number). Register an array type to own layout and add/remove buttons; without one, items render bare. Rows remount on structural changes (append/remove/move) by design — the base library re-creates the child field nodes.
Escape hatch
<FormlyForm controlRef={(control) => { /* reset, trigger, handleSubmit, getValues, … */ }} … />TypeScript
defineFields narrows props by the registered type name. Pick up custom registrations with
BuilderTypes:
const builder = createFormlyFormBuilder().registerType("rating", Rating);
const fields = defineFields<BuilderTypes<typeof builder>>([
{ key: "score", type: "rating", props: { max: 10 } }, // ✓ typed
]);License
MIT
