preact-signal-formly
preact-signal-formly 1.0.0

Config-driven forms for Preact. Signal-first, zero re-render.

Contents

Contents

preact-signal-formly

Dynamic, config-driven forms for Preact — a Formly analogue built entirely on @preact/signals and @dmytromykhailiuk/preact-signal-hook-forms.

You describe a form as data. The library renders it, wires validation, and keeps a model signal in sync. Field components mount once — every update after that, whether a value, a dynamic prop, a validation message or a field appearing and disappearing, travels through signals bound directly to DOM attributes and text nodes. Nothing re-renders.

zero re-render JSON-serialisable config per-field validation timing no eval typed end to end

Getting started

Install#

sh
npm i @dmytromykhailiuk/preact-signal-formly @dmytromykhailiuk/preact-signal-hook-forms @preact/signals preact
Peer requirements

@preact/signals ^2.0.0, preact >=10.25.0, @dmytromykhailiuk/preact-signal-hook-forms >=0.1.1. Signals v2 matters: For and Show live in the /utils subpath, and the whole rendering strategy depends on them.

Quick start#

Build a form component once at module scope, then feed it three writable signals.

tsx
import { signal } from "@preact/signals";
import { createFormlyFormBuilder, defineFields } from "@dmytromykhailiuk/preact-signal-formly";

// 1. Build once — register whatever 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}
      formOptions={{ mode: "onBlur" }}
      onSubmit={(values) => console.log(values)}
    >
      <button type="submit">Send</button>
    </FormlyForm>
  );
}

The builder is separate from the component on purpose: build() takes an immutable snapshot of the registry, so a registration made afterwards can never change a form that is already running.

The signal rules#

Everything the API hands you is a signal, a callback, or a stable reference — never a plain value that changes. Three rules keep the zero-re-render promise intact when you write your own types:

  1. Never read signal.value in a component body. That subscribes the component and causes a re-render. Unwrap only inside computed / useComputed / effect / useSignalEffect.
  2. Pass signals straight to the DOMdisabled={disabledComputed}, <span>{label}</span>. Preact binds them without re-rendering the component.
  3. Use <For> and <Show> from @preact/signals/utils for lists and conditionals, instead of mapping arrays and writing ternaries in JSX.
tsxThe pattern every built-in type follows
createFieldType(({ id, control, namePath, props, fieldState }) => {
  // Runs exactly once. Everything dynamic below is a signal.
  const placeholder = useComputed(() => props.value.placeholder ?? "");
  const invalid = useComputed(() => (fieldState.error.value ? "true" : "false"));
  return <input {...control.register(namePath)} id={id} placeholder={placeholder} aria-invalid={invalid} />;
});

Reference

The form component#

builder.build<Model>() returns a component. It owns the underlying form control, the two-way syncing, and the field tree.

Props#

modelSignal<Model>

Two-way synced with the form values. Typing updates model.value; writing a new object to model.value updates the inputs.

Treat the value as immutable — always write a new object. A deep-equal write is absorbed as a no-op; a genuinely different value goes through setValue, which dirties the form. Use controlRef + control.reset() to set a new pristine baseline instead.

configSignal<FormlyFieldConfig[]>

Read reactively. Replace it with a new array and mounted fields update in place through signals — matched by key, kind and type — without remounting. Change a field's key/kind/type and that row remounts.

formStateSignal<Record<string, any> | undefined>

Two-way synced with form.formState.shared — a writable scratch signal for cross-field or app state that the library itself never touches. Expressions and validators can read it, so it is the natural place for "which wizard step am I on".

formOptionsOmit<UseFormOptions<Model>, "defaultValues">

Forwarded to the base library's useForm, read once at mount. See below.

onSubmit(values: Model, event?: Event) => unknown | Promise<unknown>

Called only after validation passes. Without it, the form still prevents the browser's default submit.

controlRef(control: FormControl<Model>) => void

Escape hatch to the underlying control — reset, trigger, setError, getValues, handleSubmit, formState. Called once, after mount.

childrenComponentChildren

Rendered inside the <form> after the fields — the submit button usually.

classNamestring

Applied to the <form> element.

formOptions#

Option Meaning
mode When fields validate before the first submit — see When validation runs. Default "all".
reValidateMode When they re-validate after submit, or while showing an error. Default "onChange".
resolver Schema validation (zod, yup, …) for the whole model, from the base library.
criteriaMode "firstError" (default) or "all".
delayError Milliseconds before a new error is shown. Clearing is always instant.
shouldFocusError Focus the first invalid field on a failed submit. Default true.
shouldUnregister Drop a field's value when it unregisters. Default false.
Two caveats

defaultValues is not accepted — the model prop and each field's defaultValue own that.

formOptions is read once at mount, like useForm itself. Changing the prop later has no effect. Per-field timing, by contrast, is live and can be changed by replacing the config.

Field config#

The three kinds#

A field is exactly one of three things, decided by which property it carries:

Kind Marked by Owns a value Needs a key
Leaf type Yes — one value at its path Yes
Group fieldGroup No — it nests other fields No (keyless = transparent)
Array fieldArray Yes — an array of items Yes

Keys compose into dot-paths automatically: a leaf city inside a group address is address.city; inside an array it becomes phones.0.number. A group with no key is purely a layout wrapper — it adds a <div> and no path segment.

Every option#

keystring | number

Path segment relative to the parent. Required for leaves and arrays — omitting it throws with the field's type named.

typestring

Name of a type registered on the builder. An unknown name throws, listing what is registered.

propsRecord<string, any>

Everything the type component reads. The built-in props also drive validation rules and the built-in wrapper. Custom props are free-form.

defaultValueany

Applied when the model holds no value at this path. See defaultValue.

classNamestring

Class for the field's wrapper element. Read by types and wrappers as ctx.className (a signal), with expressions["className"] already applied.

wrappersstring[]

Overrides the type's default wrappers. wrappers[0] ends up outermost. Fixed at mount — changing it later needs a remount.

hideboolean | Signal | ((ctx) => boolean) | string

Hides the field. The value and its registration are kept while hidden, so a hidden field still validates and still appears in the model — hiding is presentation, not removal. To drop a field entirely, take it out of the config.

A bare string here is read as an expression.

expressions{ hide?, className?, "props.<name>"? }

Dynamic overrides evaluated inside computeds. See Expressions.

validators{ validation?: string[]; [name]: fn | entry }

Registered validators by name, plus inline ones. See Validation.

validation{ messages?, mode?, reValidateMode? }

Per-field message overrides and per-field validation timing.

fieldGroupFormlyFieldConfig[]

Nested fields. Makes this field a group.

fieldGroupClassNamestring

Class on the group's wrapping <div>. Reactive to config changes.

fieldArrayFormlyFieldConfig | ((index: number) => FormlyFieldConfig)

Template for each item. As a function it receives the row index, so rows can differ. Makes this field an array.

hooks{ onInit?, onDestroy? }

Lifecycle for a mounted field. See Field hooks.

defaultValue#

defaultValue applies when the model has no value at that path — the model always wins. It is not just written into the field: it is registered as the control's default for that path, which means the field starts pristine (isDirty === false) and control.reset() restores it rather than clearing it.

It works on every kind of field, and on fields that only appear in the config later.

ts
defineFields([
  // Leaf: a scalar.
  { key: "role", type: "select", defaultValue: "viewer", props: { options } },

  // Group: a whole object. Children it covers keep quiet; "last" still applies its own.
  {
    key: "user",
    defaultValue: { first: "Jane" },
    fieldGroup: [
      { key: "first", type: "input" },
      { key: "last", type: "input", defaultValue: "Doe" },
    ],
  },

  // Array: the initial rows, in place before the row list is built.
  {
    key: "phones",
    defaultValue: [{ number: "" }],
    fieldArray: { fieldGroup: [{ key: "number", type: "input" }] },
  },
]);
Precedence

Model value → outer field's defaultValue → inner field's defaultValue. An outer group that seeds a whole object covers the children inside it; a child whose path the object did not mention still applies its own default.

The value is deep-cloned on the way in, so the form can never mutate the config object you passed — and you can never mutate the form's defaults by editing it later. An array field whose defaultValue is not an array throws immediately, naming the path.

Expressions#

An expression makes part of a field config dynamic. All of them are evaluated inside computeds, so reading the model tracks it — the field updates without anything re-rendering.

Four forms#

ts
expressions: {
  "props.label": "Static value",                          // 1. a plain value
  "props.max": someSignal,                                // 2. a signal
  "props.disabled": ({ model }) => !model.value.country,  // 3. a callback
  "props.hint": { $expr: "model.value.country" },         // 4. a string expression
}

A callback receives the expression context:

Member Type What it is
model ReadonlySignal<Model> The whole form model.
formState Signal<…> The shared scratch state.
field ReadonlySignal<FormlyFieldConfig> This field's resolved config.
control FormControl<Model> The controller, scoped to this field.
namePath string This field's dot-path.

Keys#

Key Effect
"hide" Visibility. Takes precedence over the top-level hide. Accepts a bare string.
"className" Overrides config.className. Types and wrappers read the result as ctx.className.
"props.<name>" Overrides one prop, merged over type defaults and config.props.
Unknown keys are reported

Anything else is a mistake and is reported once through console.error, naming the field — it used to be silently ignored, which made a one-letter typo look like a broken field. The form keeps working; only that key is dropped.

TypeScript rejects a wrong prefix ("prop.disabled") at compile time. A wrong prop name ("props.disbaled") is only catchable at runtime — which is exactly the case that matters for JSON configs, where there are no types at all.

String expressions#

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 — the entire config below is JSON.parse-able, with no JavaScript in it:

json
{
  "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'" },
    "className": { "$expr": "model.value.compact ? 'row tight' : 'row'" }
  }
}

hide accepts a bare string: its target is a boolean, so a string there could never have been a meaningful static value. Everywhere else the string must be wrapped in { "$expr": "..." } — otherwise "props.label": "Name" would be parsed as code instead of staying the label it obviously is.

Why there is no eval

The string is parsed and interpreted, never compiled. This is not primarily a CSP question. A config from a backend is untrusted input, and new Function(configString) would be remote code execution in your user's browser — anyone able to influence that response, or the data behind it, would run arbitrary JavaScript in the user's session.

The grammar is the security boundary: there is no AST node for assignment, for new, or for calling an arbitrary function, so no expression can reach window, fetch or constructor regardless of what it contains.

Grammar#

Allowed Examples
Roots model, formState, field, namePath — and nothing else
Member access model.value.address.country, model.value.items[0].id, ?. accepted
Literals 'text', 42, true, false, null, undefined
Operators ! - + * / % === !== == != < > <= >= && || ?? a ? b : c
String methods includes startsWith endsWith indexOf lastIndexOf slice toLowerCase toUpperCase trim charAt at split
Array methods includes indexOf lastIndexOf slice join at
Number methods toFixed toString
  • Member access never throws. model.value.a.b.c with a missing a yields undefined, because a JSON author cannot be expected to write ?. at every step.
  • A malformed expression throws, quoting the source and the position — that is a broken config, not a runtime state.
  • Methods are matched by identity against the built-in prototypes, so an object in your own model that happens to have an includes property cannot be invoked through an expression.
  • repeat, padStart and padEnd are excluded on purpose: with an untrusted expression they are a denial-of-service primitive.
  • constructor, __proto__ and prototype cannot be read at all, including through a computed key.
  • Parsed expressions are cached by source, so a string is parsed once no matter how often it is evaluated.

Validation#

Three layers#

All three report per field as signals, with resolved human-readable messages.

tsRules from props · registered validators · inline validators
// 1. Built-in rules, expressed as props.
{ key: "age", type: "input", props: { type: "number", required: true, min: 18, max: 120 } }
{ key: "code", type: "input", props: { minLength: 4, maxLength: 8, pattern: /^[A-Z]+$/ } }

// 2. Validators registered on the builder, referenced by name.
builder.registerValidator("email", (value) => !value || value.includes("@"), "Invalid email");
{ key: "email", type: "input", validators: { validation: ["email"] } }

// 3. Inline validators. The key becomes the error type used for message lookup.
{
  key: "password",
  type: "input",
  validators: {
    strong: (value) => /[0-9]/.test(value) || "Needs a digit",
    matches: {
      expression: (value, model) => value === model.confirm,
      message: "Passwords do not match",
    },
  },
}

A validator receives (value, model, fieldConfig, abortSignal?) and returns true/undefined for valid, false for invalid (message resolved from the registry), or a string used directly as the message. Async validators are supported and get an AbortSignal that fires when a newer run supersedes them.

tsAsync, with cancellation
builder.registerValidator("uniqueSlug", async (value, _model, _field, signal) => {
  if (!value) return true;
  const res = await fetch(`/api/slug/${value}`, { signal });
  return (await res.json()).free || "That slug is taken";
});

Rules are read through getters that peek the current config, so editing props.min through a config replacement takes effect on the next validation run — the field does not need to re-register.

Messages#

Precedence, first match wins:

  1. the field's validation.messages[errorType]
  2. the inline validator entry's own message
  3. a message registered with registerValidationMessage(errorType, …)
  4. the default message given to registerValidator
  5. the string the validator returned
  6. the error type itself, as a last resort

Any message may be a function of (error, fieldConfig):

ts
builder.registerValidationMessage("minLength", (_error, field) =>
  `${field.props?.label ?? "This field"} needs at least ${field.props?.minLength} characters`,
);

// Or for one field only:
{ key: "pin", type: "input", props: { minLength: 4 }, validation: { messages: { minLength: "4 digits" } } }

Defaults shipped

Error type Message
required This field is required
min / max Value must be at least / at most n
minLength / maxLength Must be at least / at most n characters
pattern Invalid format

When validation runs#

mode governs behaviour before the first submit. reValidateMode takes over once the form has been submitted or while the field is showing an error — that pairing is what lets a form stay quiet until submit and then turn responsive per keystroke.

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 or an explicit trigger().

reValidateMode is "onChange" (default), "onBlur" or "onSubmit". With mode: "all" it changes nothing on its own — it only matters once you loosen mode.

This default differs from the base library

The base library defaults to "onSubmit"; formly defaults to "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. To get the quiet behaviour back, ask for it: formOptions={{ mode: "onSubmit" }}.

Set it for the whole form through formOptions, and override it per field in the config. A field can be both stricter and looser than its form:

tsx
<FormlyForm … formOptions={{ mode: "onBlur", reValidateMode: "onChange" }} />
ts
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" } },
  // Only the re-validation behaviour differs.
  { key: "email", type: "input", validation: { reValidateMode: "onBlur" } },
]);
How it works

The base library decides timing globally, with no per-field hook — so formly takes the decision over. The underlying control is built in a mode where it never auto-validates, and the same rules are re-evaluated per field against that field's effective timing. Both binding paths are covered: register's onChange/onBlur callbacks (composed with any you pass yourself, never replacing them) and afterChange/afterBlur for Controller/useController.

Field timing is resolved at decision time, not captured at mount, so replacing the config changes the mode of a field that is already on screen. Explicit validation — trigger(), handleSubmit(), setValue({ shouldValidate: true }) — is unaffected and always runs.

Builder API#

Every method returns the builder, so registrations chain. build() snapshots them.

createFormlyFormBuilder(options?: { builtIns?: boolean })

Creates a builder with the built-in types, the "field" wrapper and the default messages already registered. Pass { builtIns: false } to start empty.

registerType(name, component, options?)

Registers a leaf type. Re-registering a name overrides it — that is how you replace a built-in. Options: wrappers (defaults for this type), defaultProps, and extends (inherit wrappers/defaultProps from another type; the component is not inherited). A circular extends chain throws.

registerArrayType(name, component)

Registers a type for array fields — it owns the layout and the add/remove controls. See Array types.

registerWrapper(name, component)

Registers a wrapper, referenced from wrappers on a type or a field.

registerLazyType(name, loader, options?)

Registers a leaf type whose component is code-split: the loader is a function returning a dynamic import(), called when a field of this type first renders and at most once per registration. Takes the same wrappers/defaultProps/extends options as registerType — they are eager — plus errorFallback. See Lazy types & wrappers.

registerLazyArrayType(name, loader, options?)

The code-split counterpart of registerArrayType. Items render once the chunk lands. Options: errorFallback.

registerLazyWrapper(name, loader, options?)

The code-split counterpart of registerWrapper. A wrapper owns its children, so the wrapped field renders once the wrapper lands. Options: errorFallback.

registerValidator(name, fn, defaultMessage?)

Registers a validator used via validators: { validation: ["name"] }.

registerValidationMessage(errorType, message)

Registers a message for an error type, built-in rule types included.

registerExtension(name, extension)

Registers a config transform. See Extensions.

build<Model>() => FormlyFormComponent<Model>

Snapshots the registry and returns the form component. Later registrations never affect it.

ts
const builder = createFormlyFormBuilder()
  .registerType("rating", RatingType, { wrappers: ["field"], defaultProps: { max: 5 } })
  .registerType("stars", StarsType, { extends: "rating" })   // inherits wrappers + defaultProps
  .registerArrayType("phones", PhonesArray)
  .registerWrapper("highlight", HighlightWrapper)
  .registerLazyType("editor", () => import("./types/Editor"), { wrappers: ["field"] })
  .registerLazyArrayType("gallery", () => import("./types/Gallery"))
  .registerLazyWrapper("card", () => import("./wrappers/Card"))
  .registerValidator("email", emailValidator, "Please enter a valid email")
  .registerValidationMessage("required", "Don't leave this empty")
  .registerExtension("autoLabel", autoLabelExtension);

const FormlyForm = builder.build<Model>();

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. registerLazyType, registerLazyArrayType and registerLazyWrapper 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.

tsx
const builder = createFormlyFormBuilder()
  .registerLazyType("rating", () => import("./types/Rating"), {
    wrappers: ["field"],
    defaultProps: { max: 5 },
  })
  .registerLazyArrayType("phones", () => import("./types/PhonesArray"))
  .registerLazyWrapper("card", () => import("./wrappers/Card"));

// Used in a config exactly like an eagerly registered name:
const fields = [{ key: "score", type: "rating" }];
The loader() => Promise<Component | { default: Component }>

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: () => import("./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 flightnothing renders

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. hooks.onInit fires at field mount too.

Registration optionseager — 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 one renders nothing until it lands; nesting order (wrappers[0] outermost) holds for eager, lazy and mixed chains alike.

errorFallback(error) => VNode | null

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, errorFallback renders in the slot (without it the slot stays empty), and the import is retried the next time a field using that name mounts.

Zero re-renderstill 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.

Typingsame type map as registerType

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", loader).

tsx
builder.registerLazyWrapper("card", () => import("./wrappers/Card"), {
  errorFallback: (error) => (
    <p class="load-error">Could not load this field: {String(error)}</p>
  ),
});

Built-ins#

Types

Name Renders Notes
input <input> props.type selects the HTML type. With type: "number" the model receives a number.
textarea <textarea>
select <select> Options from props.options.
checkbox <input type="checkbox"> Boolean value; checked is the source of truth.
radio a radiogroup Options from props.options. Non-string option values survive selection.

All of them are exported (InputType, TextareaType, SelectType, CheckboxType, RadioType) so you can compose or re-register them.

The props built-in types understand

Prop Effect
label, description Rendered by the "field" wrapper.
placeholder, disabled, type Passed to the element.
options { value, label, disabled? }[] for select and radio.
required, min, max, minLength, maxLength, pattern Mapped to validation rules. pattern takes a RegExp or a string.

Anything else you put in props is yours — FormlyBaseProps has an index signature.

The "field" wrapper

The default wrapper for every built-in type. Renders a <div> with ctx.className (falling back to formly-field), a <label for> with a * marker when required, the control, an optional description, and the error message in a role="alert" element with class formly-error. It ships unstyled — the class names are the styling hooks.

Starting empty

createFormlyFormBuilder({ builtIns: false }) registers nothing. You can also call registerBuiltIns(builder) yourself later, or import defaultValidationMessages and register only the ones you want.

Writing a field type#

A field type is a Preact component whose props are the field context. createFieldType is an identity helper that exists purely to give you the typing and an optional display name.

Binding the input#

You receive control and namePath and bind the input yourself, exactly as you would with the base library outside of formly. Two ways:

Uncontrolled — spread register

tsx
import { useComputed } from "@preact/signals";
import { createFieldType } from "@dmytromykhailiuk/preact-signal-formly";

const Text = createFieldType<{ placeholder?: string }>(
  ({ control, namePath, props, errorMessage, id }) => {
    const placeholder = useComputed(() => props.value.placeholder ?? "");
    return (
      <div>
        <input {...control.register(namePath)} id={id} placeholder={placeholder} />
        <span role="alert">{errorMessage}</span>
      </div>
    );
  },
  "Text",
);

Controlled — Controller or useController

For widgets with no DOM input of their own:

tsx
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}
      render={({ field }) => (
        <div id={id}>
          {/* field.value is a signal; field.onChange/onBlur are callbacks */}
          <span>{field.value}</span> / <span>{max}</span>
          <button type="button" onClick={() => field.onChange((field.value.peek() ?? 0) + 1)}>+</button>
        </div>
      )}
    />
  );
});
The control is scoped to your field

control.register(namePath) carries the rules derived from the field config — required, pattern, your validators. Options you pass explicitly win over them, and your own onChange/onBlur are composed with the library's, not replaced.

Controller and useController leave the rules alone, so they work too — and a type that binds nothing at all still validates, because the rules live on the field node from mount.

Context reference#

Member Type What it is
control FormControl<Model> The controller, scoped to this field.
namePath FieldPath<Model> Dot-path — the field's name.
props ReadonlySignal Type defaults + config.props + props.* expressions.
className ReadonlySignal<string?> config.className with its expression applied.
config ReadonlySignal The resolved config, reactive to edits.
formState Signal The shared scratch state.
id string Stable DOM id for label association.
fieldState FieldState Signals: error, isDirty, isTouched, isValidating, invalid.
errorMessage ReadonlySignal<string?> The resolved message for the current error.

Wrappers get all of the above plus children. Array types get all of it plus array and renderItems. Hooks get the base members only — no fieldState, no errorMessage.

Wrappers#

A wrapper decorates a field — label, error, layout. wrappers[0] is the outermost.

tsx
const Card = createWrapper(({ props, errorMessage, className, children, id }) => {
  const label = useComputed(() => props.value.label);
  const cls = useComputed(() => className.value ?? "card");
  return (
    <div class={cls}>
      <label for={id}>{label}</label>
      {children}
      <span class="formly-error">{errorMessage}</span>
    </div>
  );
}, "Card");

builder.registerWrapper("card", Card);
// then: { key: "email", type: "input", wrappers: ["card"] }

Array types#

An array type owns the layout and the controls for a repeating field. renderItems() renders the rows; array is the stable API from the base library.

tsx
const PhonesArray = createArrayType<{ number: string }>(({ array, renderItems, props }) => {
  const label = useComputed(() => props.value.label ?? "Items");
  return (
    <div>
      <label>{label}</label>
      {renderItems()}
      <button type="button" onClick={() => array.append({ number: "" })}>+ phone</button>
      <button type="button" onClick={() => array.remove(array.fields.peek().length - 1)}>− last</button>
    </div>
  );
});

builder.registerArrayType("phones", PhonesArray);

array carries fields (a signal of rows with stable ids), append, prepend, insert, remove, swap, move and replace. Without a registered array type, the rows render bare from the template.

Groups & arrays#

ts
defineFields([
  // Keyed group → paths become address.country, address.city
  {
    key: "address",
    fieldGroupClassName: "row",
    fieldGroup: [
      { key: "country", type: "select", props: { label: "Country", options } },
      { key: "city", type: "input", props: { label: "City" }, hide: "!model.value.address.country" },
    ],
  },

  // Keyless group → pure layout, no path segment
  {
    fieldGroupClassName: "row",
    fieldGroup: [
      { key: "first", type: "input" },
      { key: "last", type: "input" },
    ],
  },

  // Array → phones.0.number, phones.1.number …
  {
    key: "phones",
    type: "phones",                       // a registered array type (optional)
    props: { label: "Phones", required: true },
    defaultValue: [{ number: "" }],
    fieldArray: {
      className: "phones-item",
      fieldGroup: [{ key: "number", type: "input", props: { minLength: 5 } }],
    },
  },

  // Array with a per-row template
  {
    key: "steps",
    fieldArray: (index) => ({
      fieldGroup: [{ key: "name", type: "input", props: { label: `Step ${index + 1}` } }],
    }),
  },
]);

Array-level rules attach to the array node itself, so props.required or props.minLength on an array field validates the list rather than an item. Rows remount on every structural change — the base library drops and recreates child nodes on append/remove/move, so a controller must never survive one.

Field hooks#

onInit runs once after the field mounts and may return a cleanup function. onDestroy runs when the field unmounts — including when it is removed from the config, which is exactly when the config no longer describes it.

ts
{
  key: "country",
  type: "select",
  hooks: {
    onInit: ({ control, namePath, props }) => {
      const dispose = effect(() => {
        // Runs whenever the value changes — no re-render involved.
        console.log(namePath, control.watch(namePath).value);
      });
      return dispose;              // cleanup, called on unmount
    },
    onDestroy: ({ namePath }) => console.log("gone:", namePath),
  },
}

The context is the base field context — control, namePath, props, className, config, formState, id.

Extensions#

An extension mutates the config draft during resolution — Formly semantics, for cross-cutting conventions you do not want to repeat in every field. The draft is a private clone, so the config object you passed is never touched.

Order per field: prePopulate → type defaults merge → onPopulatepostPopulate.

ts
const autoLabel = createExtension({
  prePopulate: (field) => {
    if (field.key && !field.props?.label) {
      const key = String(field.key);
      field.props = { ...field.props, label: key[0].toUpperCase() + key.slice(1) };
    }
  },
});

builder.registerExtension("autoLabel", autoLabel);

Extensions run inside a computed on every resolution, so they must be pure with respect to their input — the same draft in, the same draft out.

TypeScript#

defineFields narrows props by the registered type name. Pass BuilderTypes<typeof builder> to pick up your own registrations:

ts
import { defineFields, type BuilderTypes } from "@dmytromykhailiuk/preact-signal-formly";

interface RatingProps { max: number; [key: string]: any }

const builder = createFormlyFormBuilder().registerType<"rating", RatingProps>("rating", Rating);

const fields = defineFields<BuilderTypes<typeof builder>>([
  { key: "score", type: "rating", props: { max: 5 } },   // `max` is checked
  { key: "name", type: "input", props: { label: "Name" } },
]);

Without a type parameter, defineFields uses the built-in types. Unregistered names and array fields fall back to the loose FormlyFieldConfig, so nothing is ever blocked by the typing — it only narrows what it can.

Field types take their own generics: createFieldType<Props, Model>, createArrayType<Item>, createValidator<Value, Model>.

Escape hatches#

tsx
let control: FormControl<Model>;

<FormlyForm … controlRef={(c) => (control = c)} />

await control.trigger();                       // validate everything
await control.trigger("address.city");         // …or one path
control.reset();                               // back to the defaults, pristine
control.setError("email", { type: "server", message: "Already taken" });
control.setFocus("email");
control.formState.isValid.value;               // signals, all of them
control.formState.dirtyFields.value;

RegistryContext is exported too, for building your own rendering on top of the registry a builder produced.

Exports#

Values

createFormlyFormBuilder · defineFields · createFieldType · createWrapper · createArrayType · createValidator · createExtension · registerBuiltIns · defaultValidationMessages · InputType · TextareaType · SelectType · CheckboxType · RadioType · FieldWrapper · RegistryContext

Types

FormlyFieldConfig · FormlyBaseProps · FormlyExpression · FormlyExpressions · FormlyExpressionKey · FormlyExprRef · FormlyExpressionCtx · FormlyFieldContext · FormlyFieldBaseContext · FormlyHookContext · WrapperContext · FieldArrayContext · FieldTypeComponent · WrapperComponent · ArrayTypeComponent · FormlyFormProps · FormlyFormComponent · FormlyFormBuilder · FormlyBuilderOptions · FormlyExtension · FormlyFieldHooks · FormlySelectOption · FormlySharedState · FormlyValidatorFn · FormlyValidatorEntry · FormlyValidatorsConfig · FormlyValidationMessage · TypeRegistrationOptions · LazyTypeRegistrationOptions · LazyRegistrationOptions · LazyComponentLoader · LazyComponentModule · BuilderTypes · TypedFieldConfig · BuiltInTypes · FormlyRegistry

Re-exported from the base library so you can type them without importing it: ValidationMode · ReValidateMode · UseFormOptions.