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.
Getting started
Install#
npm i @dmytromykhailiuk/preact-signal-formly @dmytromykhailiuk/preact-signal-hook-forms @preact/signals preact
@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.
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:
-
Never read
signal.valuein a component body. That subscribes the component and causes a re-render. Unwrap only insidecomputed/useComputed/effect/useSignalEffect. -
Pass signals straight to the DOM —
disabled={disabledComputed},<span>{label}</span>. Preact binds them without re-rendering the component. -
Use
<For>and<Show>from@preact/signals/utilsfor lists and conditionals, instead of mapping arrays and writing ternaries in JSX.
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#
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.
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.
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".
Forwarded to the base library's useForm, read
once at mount. See below.
Called only after validation passes. Without it, the form still prevents the browser's default submit.
Escape hatch to the underlying control — reset,
trigger, setError,
getValues, handleSubmit,
formState. Called once, after mount.
Rendered inside the <form> after the fields
— the submit button usually.
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.
|
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#
Path segment relative to the parent. Required for leaves and arrays — omitting it throws with the field's type named.
Name of a type registered on the builder. An unknown name throws, listing what is registered.
Everything the type component reads. The built-in props also drive validation rules and the built-in wrapper. Custom props are free-form.
Applied when the model holds no value at this path. See defaultValue.
Class for the field's wrapper element. Read by types and
wrappers as ctx.className (a signal), with
expressions["className"] already applied.
Overrides the type's default wrappers.
wrappers[0] ends up outermost.
Fixed at mount — changing it later needs a remount.
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.
Dynamic overrides evaluated inside computeds. See Expressions.
Registered validators by name, plus inline ones. See Validation.
Per-field message overrides and per-field validation timing.
Nested fields. Makes this field a group.
Class on the group's wrapping <div>.
Reactive to config changes.
Template for each item. As a function it receives the row index, so rows can differ. Makes this field an array.
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.
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" }] },
},
]);
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#
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.
|
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:
{
"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.
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.cwith a missingayieldsundefined, 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
includesproperty cannot be invoked through an expression. -
repeat,padStartandpadEndare excluded on purpose: with an untrusted expression they are a denial-of-service primitive. -
constructor,__proto__andprototypecannot 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.
// 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.
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:
- the field's
validation.messages[errorType] - the inline validator entry's own
message -
a message registered with
registerValidationMessage(errorType, …) - the default message given to
registerValidator - the string the validator returned
- the error type itself, as a last resort
Any message may be a function of (error, fieldConfig):
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.
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:
<FormlyForm … 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" } },
// Only the re-validation behaviour differs.
{ key: "email", type: "input", validation: { reValidateMode: "onBlur" } },
]);
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.
Creates a builder with the built-in types, the
"field" wrapper and the default messages already
registered. Pass { builtIns: false } to start
empty.
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.
Registers a type for array fields — it owns the layout and the add/remove controls. See Array types.
Registers a wrapper, referenced from wrappers on
a type or a field.
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.
The code-split counterpart of
registerArrayType. Items render once the chunk
lands. Options: errorFallback.
The code-split counterpart of registerWrapper. A
wrapper owns its children, so the wrapped field renders once
the wrapper lands. Options: errorFallback.
Registers a validator used via
validators: { validation: ["name"] }.
Registers a message for an error type, built-in rule types included.
Registers a config transform. See Extensions.
Snapshots the registry and returns the form component. Later registrations never affect it.
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.
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" }];
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.
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.
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.
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.
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.
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).
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.
createFormlyFormBuilder({ builtIns: false })
registers nothing. You can also call
registerBuiltIns(builder) yourself later, or import
defaultValidationMessages and register only the ones
you want.
Groups & arrays#
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.
{
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 →
onPopulate → postPopulate.
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:
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#
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.