UI kit recipes
These recipes keep dynamic-field-kit in charge of values, touched state and validation while a UI kit owns presentation. The rule is the same everywhere: display error only when touched, forward id and the three aria props, put makeErrorId(id) on the element showing the message, and call the supplied value and blur callbacks.
See Forward the aria props for why the aria half is not optional polish.
React + Ant Design
import { Form, Input } from 'antd';
import {
fieldRegistry,
makeErrorId,
type FieldRendererProps,
} from '@dynamic-field-kit/react';
function AntText(props: FieldRendererProps<string>) {
const message = props.touched
? [props.error].flat().filter(Boolean)[0]
: undefined;
return (
<Form.Item
label={props.label}
required={props.required}
validateStatus={message ? 'error' : undefined}
// The id is what `ariaDescribedBy` points at. Without it the reference
// dangles and a screen reader has nothing to read out.
help={
message ? <span id={makeErrorId(props.id!)}>{message}</span> : undefined
}
>
<Input
id={props.id}
value={props.value ?? ''}
placeholder={props.placeholder}
disabled={props.disabled}
readOnly={props.readOnly}
status={message ? 'error' : undefined}
aria-invalid={props.ariaInvalid}
aria-required={props.ariaRequired}
aria-describedby={props.ariaDescribedBy}
onChange={(event) => props.onValueChange?.(event.target.value)}
onBlur={props.onBlur}
/>
</Form.Item>
);
}
fieldRegistry.register('text', AntText);
fieldRegistry.register('email', AntText);
fieldRegistry.register('password', AntText);const form = useDynamicForm({ fields, initialValues });
<form onSubmit={form.handleSubmit(save)}>
<MultiFieldInput fieldDescriptions={fields} form={form} />
<button disabled={!form.isValid || form.isSubmitting}>Save</button>
</form>;API references: Ant Design Input and Form.
Vue + Vuetify
import { defineComponent, h, type PropType } from 'vue';
import { VTextField } from 'vuetify/components';
import { fieldRegistry } from '@dynamic-field-kit/vue';
const VuetifyText = defineComponent({
props: {
id: String,
value: String,
label: String,
placeholder: String,
disabled: Boolean,
readOnly: Boolean,
touched: Boolean,
error: [String, Array] as PropType<string | string[]>,
onValueChange: Function as PropType<(value: string) => void>,
onBlur: Function as PropType<() => void>,
},
setup(props) {
return () =>
h(VTextField, {
id: props.id,
modelValue: props.value ?? '',
label: props.label,
placeholder: props.placeholder,
disabled: props.disabled,
readonly: props.readOnly,
errorMessages: props.touched ? props.error : undefined,
'onUpdate:modelValue': props.onValueChange,
onBlur: props.onBlur,
});
},
});
fieldRegistry.register('text', VuetifyText);
fieldRegistry.register('email', VuetifyText);
fieldRegistry.register('password', VuetifyText);<script setup lang="ts">
const submit = form.handleSubmit(save);
</script>
<form @submit="submit">
<MultiFieldInput :field-descriptions="fields" :form="form" />
<v-btn type="submit" :disabled="!form.isValid.value" :loading="form.isSubmitting.value">
Save
</v-btn>
</form>API reference: Vuetify text fields.
Angular + Angular Material
import { Component } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { BaseInputComponent, fieldRegistry } from '@dynamic-field-kit/angular';
@Component({
selector: 'app-material-text',
standalone: true,
imports: [MatFormFieldModule, MatInputModule],
template: `
<mat-form-field appearance="outline">
<mat-label>{{ label }}</mat-label>
<input
matInput
[id]="id"
[value]="value ?? ''"
[placeholder]="placeholder ?? ''"
[required]="required ?? false"
[disabled]="disabled ?? false"
[readOnly]="readOnly ?? false"
[errorStateMatcher]="errorStateMatcher"
(input)="valueChange.emit($any($event.target).value)"
/>
@if (touched && error) {
<mat-error>{{ errorText }}</mat-error>
}
</mat-form-field>
`,
})
export class MaterialTextRenderer extends BaseInputComponent {
readonly errorStateMatcher: ErrorStateMatcher = {
isErrorState: () => Boolean(this.touched && this.error),
};
get errorText(): string {
return ([] as string[]).concat(this.error ?? [])[0] ?? '';
}
}
fieldRegistry.register('text', MaterialTextRenderer as never);Bind both metadata maps so the store is the single source of truth:
<dfk-multi-field-input
[fieldDescriptions]="fields"
[properties]="store.data()"
[touched]="store.touched()"
[errors]="store.errors()"
(onChange)="store.handleChange($event)"
(onBlurField)="store.handleBlur($event)"
/>API references: Angular Material form field and input.
Async validation
All three form helpers expose validateAsync(). Their handleSubmit() methods run one async-capable validation pass before calling onValid. Live isValid reflects synchronous rules only; call validateAsync() when UI must check an async rule before submit.
Declare a Promise-returning validator so the live pass skips it instead of firing a request per keystroke, and honour the signal it is handed:
{
name: 'username',
type: 'text',
validationMode: 'async',
validate: (value, _data, _rootData, context) =>
fetch(`/api/available?u=${value}`, { signal: context?.signal })
.then((r) => (r.ok ? undefined : 'Already taken')),
}For the UI, bind the three status members rather than isValid alone - isValid is true while an async rule is still unanswered:
| Member | Use it for |
|---|---|
isValidating | A spinner on the field or a busy state on the submit button |
validationStatus | `'valid' |
isValidationComplete | Enabling submit only once everything has been answered |
Typing cancels a live run in flight, so a stale result never overwrites a newer one. A submit is not cancelled by typing: it validates the snapshot it was given and always calls onValid or onInvalid.
Forward the aria props
buildFieldRendererProps computes ariaInvalid, ariaRequired and ariaDescribedBy for every field, on every adapter. A custom renderer that drops them throws that work away.
This is not optional polish. focusFirstInvalidField — the helper for moving focus to the first problem after a failed submit — selects
'[aria-invalid="true"], input:invalid, select:invalid, textarea:invalid';A renderer that never sets aria-invalid makes that helper silently do nothing. It does not throw and it does not warn; the form simply fails to submit and focus stays where it was. This was the single most common gap found in real consumer renderers, in part because earlier versions of this page did not mention the aria props at all.
Three things to wire, in every renderer:
| Prop | Where it goes |
|---|---|
ariaInvalid | aria-invalid on the focusable control |
ariaRequired | aria-required on the focusable control |
ariaDescribedBy | aria-describedby on the control, and makeErrorId(id) as the id of the element rendering the message |
ariaDescribedBy is undefined while the field is valid and `${id}-error` once it has an error, so binding it unconditionally is correct — there is nothing to clear.
If you use the built-in renderers you get all of this already; they render the message node themselves.
Async options in a renderer
A field with an async loader hands the renderer three extra props. A minimal search-remote picker uses all three:
import { type FieldRendererProps } from '@dynamic-field-kit/react';
function UserPicker({
id,
options,
optionsStatus,
optionsError,
onOptionsQuery,
value,
onValueChange,
onBlur,
}: FieldRendererProps<string>) {
return (
<div>
<input
id={id}
placeholder="Search users…"
onChange={(e) => onOptionsQuery?.(e.target.value)}
onBlur={onBlur}
/>
{optionsStatus === 'loading' && <Spinner />}
{optionsStatus === 'error' && (
<span role="alert">{String((optionsError as Error)?.message)}</span>
)}
<ul>
{(options ?? []).map((o) => (
<li key={String(o.value)}>
<button onClick={() => onValueChange?.(o.value as string)}>
{String(o.label)}
</button>
</li>
))}
</ul>
</div>
);
}optionsStatus is undefined for a field with static or synchronous options, so optionsStatus === 'loading' is safely false there — one renderer works for both.
Do not debounce inside the renderer. debounceMs on the field already collapses rapid onOptionsQuery calls, and a second layer would only add latency.