React adapter for @dynamic-field-kit/core.
This package provides React components for rendering FieldDescription[] and exports a React-typed fieldRegistry, so registered renderers can be used directly as JSX components.
Live demo: https://vannt-dev.github.io/dynamic-field-kit/react/ — plus
enterprise features
(useDynamicForm, HTML5 renderers, blur wiring, DevTools) and a
multi-step wizard.
npm install @dynamic-field-kit/core @dynamic-field-kit/react reactNote: @dynamic-field-kit/core, react, and react-dom are peer dependencies — this adapter does not bundle or auto-install them, so add them to your app explicitly (as shown above). Keep a single @dynamic-field-kit/core version across all adapters so they share one registry.
DynamicInputFieldInputMultiFieldInputuseDynamicFormDynamicFormDevToolslayoutRegistryfieldRegistryFieldRegistry(class, for scoped registries)FieldRegistryProvider/useFieldRegistry/FieldRegistryProviderPropsReactFieldRendererReactFieldRegistryFieldDescriptionFieldTypeKeyFieldRendererPropsLayoutConfigdefaultRenderersMap/getDefaultRenderer
Re-exported from @dynamic-field-kit/core so a consumer app rarely has to import
both packages:
validateField/validateFieldAsync— one field, returnsstring[]validateFields/validateFieldsAsync— a whole schema, returnsValidationResultcollectFieldPaths— the leaf paths a schema actually has in the data (contacts[0].email)indexGroupPathMap— index an error or touched map by repeatable-group itemmakeErrorId— the id a renderer puts on its message element soaria-describedbyresolvescreateOptionsLoader/isAsyncOptions— the async options enginecreateMessageResolver/setDefaultMessages/MessageCatalog— validation message catalogresolveDisabled/resolveReadOnly/resolveOptions— resolve a field's dynamic conditions and optionsvalidators— the built-in validator helpers (required,email,minLength,compose, …)ValidationResult/ValidationContext
useDynamicForm keeps live validation synchronous - a validator declared or
detected as async is never invoked on that path. Its handleSubmit runs one
async-capable pass, and validateAsync() is there when you need that answer
before submit. Runs are latest-wins: typing aborts the live run in flight, so a
stale result cannot overwrite a newer one, and a submit validates the snapshot
it was given under a controller of its own, so editing mid-submit no longer
cancels it. Declare a Promise-returning validator with
validationMode: 'async' and read context.signal (the fourth argument) to
cancel the request itself. See the
core README
for the full rules.
For a complete UI integration, see the Ant Design recipe.
FieldGroupInput (repeatable field groups) is used internally by FieldInput and doesn't need to be imported directly - see "Repeatable field groups" below.
Default layouts are registered automatically when you import the package root.
Built-in layouts:
columnrowgridresponsive
Register React components or function components through the React adapter:
import { fieldRegistry } from '@dynamic-field-kit/react';
fieldRegistry.register('text', ({ value, onValueChange, label }) => (
<label style={{ display: 'grid', gap: 4 }}>
<span>{label}</span>
<input
value={value ?? ''}
onChange={(e) => onValueChange?.(e.target.value)}
/>
</label>
));
fieldRegistry.register('number', ({ value, onValueChange, label }) => (
<label style={{ display: 'grid', gap: 4 }}>
<span>{label}</span>
<input
type="number"
value={value ?? ''}
onChange={(e) => onValueChange?.(Number(e.target.value))}
/>
</label>
));import { useState } from 'react';
import { MultiFieldInput } from '@dynamic-field-kit/react';
import type { FieldDescription } from '@dynamic-field-kit/core';
const fields: FieldDescription[] = [
{ name: 'name', type: 'text', label: 'Name' },
{ name: 'age', type: 'number', label: 'Age' },
];
export function Example() {
const [data, setData] = useState({});
return (
<MultiFieldInput
fieldDescriptions={fields}
properties={data}
onChange={setData}
/>
);
}Holds data, errors, touched and submission state for a set of fields. Vue's
composable and Angular's createDynamicFormStore expose the same surface.
import { useDynamicForm, MultiFieldInput } from '@dynamic-field-kit/react';
const form = useDynamicForm({
fields,
initialValues: { country: 'VN' },
validateOnBlur: true, // default
validateOnChange: false, // default
messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions
});
<form onSubmit={form.handleSubmit((data) => save(data))}>
<MultiFieldInput fieldDescriptions={fields} form={form} />
<button disabled={form.isSubmitting}>
{form.isSubmitting ? 'Saving…' : 'Save'}
</button>
</form>;form is shorthand for five state/callback props, and is the recommended wiring:
<MultiFieldInput
fieldDescriptions={fields}
properties={form.data}
onChange={form.handleChange}
onBlurField={form.handleBlur} // touched + validateOnBlur
touched={form.touched} // makes the hook the only source of truth
errors={form.errors} // renderer and hook read the same error map
/>The form shorthand also carries baselineValues, which is what keeps
per-field dirty correct across a reset. Without a form store, pass the
baseline yourself:
<MultiFieldInput
fieldDescriptions={fields}
properties={data}
initialProperties={original} // what `dirty` compares against
/>Omit it and the baseline is the first non-undefined properties the
component sees — which is what an edit form wants when its values arrive from a
fetch after mount. Note that {} counts as a real value: a form
that opens blank cannot be told apart from one still waiting on a fetch, so
pass initialProperties when properties starts as {} rather than
undefined.
Passing touched and errors gives the form store ownership of renderer
metadata. touched is what makes an invalid submit visible: handleSubmit
marks every field touched before validating, so a renderer that gates its error
on touched shows it even for fields the user never focused. reset() clears
touched the same way. Individually passed props win over the ones form
derives, so you can pass form and still override one wire.
| Member | Description |
|---|---|
data |
Current form data, with computeValue fields applied |
errors |
Record<string, string[]>, keyed like validateFields |
isValid / isDirty |
Current synchronous validity / any value has changed |
baselineValues |
The values dirty is measured against — initialValues until reset(newValues) replaces them |
getDirtyValues() |
Only the entries differing from baselineValues, for PATCH-style submits |
isValidating |
An async validation pass is in flight |
isValidationComplete |
Every applicable validator finished and none is in flight |
validationStatus |
`'valid' |
isSubmitting / isSubmitted |
In-flight submit / at least one submit attempted |
touched |
Fields that have been blurred |
handleChange(data) |
Replace the whole form data — pass to MultiFieldInput's onChange |
setFieldValue(name, value) |
Change one field |
handleBlur(name) |
Mark touched, and validate when validateOnBlur |
setFieldTouched(name, value?) |
Set touched explicitly |
touchAll() |
Mark every field touched — handleSubmit already calls it |
resetTouched() |
Clear touched only, leaving data/errors/dirty alone |
setTouched |
Raw setter for the whole touched map |
setData |
Raw state setter, for escape hatches |
validate() |
Validate now, returns a boolean |
validateAsync() |
Validate now, awaiting Promise-based rules |
reset(values?) |
Back to initialValues (or the values given), clearing errors/touched/submission |
handleSubmit(onValid, onInvalid?) |
Returns a submit handler; calls preventDefault, validates, then dispatches |
Leave touched off and MultiFieldInput falls back to tracking it internally
from blur alone, as it always did. In that mode nothing outside the component
can clear it — a form that stays mounted across submits will keep showing the
errors of the previous round after reset() — so it exposes a ref for it:
const ref = useRef<MultiFieldInputHandle>(null);
<MultiFieldInput ref={ref} fieldDescriptions={fields} />;
// after a successful submit
ref.current?.resetTouched();resetTouched(), setFieldTouched(name, value?) and getTouched() are the
handle's members. Controlled mode needs none of them: form.reset() covers it.
Each field renders with id={${idPrefix}-${name}}, where idPrefix defaults
to a value unique to the MultiFieldInput instance (from useId, so server and
client agree). Two forms containing a field of the same name therefore no longer
emit the same DOM id twice.
// pinned ids — reproduces the pre-1.6 `dfk-field-title`
<MultiFieldInput fieldDescriptions={fields} idPrefix="dfk-field" />For one field, set id on its FieldDescription; it wins over the prefix.
Renderers receive the resolved value as the id prop, so a <label htmlFor> in
a renderer points at exactly one input.
text · number · password · email · textarea · checkbox · select ·
radio · range · file · date · time · datetime-local · switch
Any type you have not registered falls back to one of these. Reach the map directly if you need to wrap or inspect a default:
import {
defaultRenderersMap,
getDefaultRenderer,
} from '@dynamic-field-kit/react';
const Base = getDefaultRenderer('date'); // undefined for an unknown typefile emits a File (or File[] when multiple is set), range and number
emit numbers, checkbox / switch emit booleans; everything else emits strings.
Since 1.7.0 a default renderer also renders its validation message, as
<div id="{fieldId}-error" class="dfk-field-error" role="alert">, which is what
aria-describedby points at. Before that they were handed error and dropped
it, so the form showed nothing. A registered custom renderer is unaffected — the
node is emitted only where a default was used, so you never get two copies. Hide
it with .dfk-field-error { display: none } if you want the old silence.
import { DynamicFormDevTools } from '@dynamic-field-kit/react';
<DynamicFormDevTools
data={form.data}
errors={form.errors}
touched={form.touched}
isDirty={form.isDirty}
fields={fields}
position="bottom-right" // or "bottom-left"
/>;A floating overlay with data / errors / meta / fields tabs. The collapsed button carries a red badge with the number of fields in error.
Use a layout name:
<MultiFieldInput fieldDescriptions={fields} layout="grid" />Use a layout config object:
<MultiFieldInput
fieldDescriptions={fields}
layout={{ type: 'grid', columns: 3, gap: 16 }}
/>Use the built-in responsive layout:
<MultiFieldInput
fieldDescriptions={fields}
layout={{
type: 'responsive',
mobile: 'column',
desktop: { type: 'grid', columns: 2, gap: 12 },
}}
/>Register a custom layout:
import { layoutRegistry } from '@dynamic-field-kit/react';
layoutRegistry.register('stack-tight', ({ children }) => (
<div style={{ display: 'grid', gap: 8 }}>{children}</div>
));Give a field a computeValue to derive its value from the rest of the form data whenever any field changes:
const fields: FieldDescription[] = [
{ name: 'firstName', type: 'text' },
{ name: 'lastName', type: 'text' },
{
name: 'fullName',
type: 'text',
computeValue: (data) =>
`${data.firstName ?? ''} ${data.lastName ?? ''}`.trim(),
},
];Declare a validate hook and dynamic disabledCondition/readOnlyCondition;
your renderer receives error, disabled, and readOnly. MultiFieldInput
emits onValidityChange:
<MultiFieldInput
fieldDescriptions={fields}
properties={data}
onChange={setData}
onValidityChange={({ valid, errors }) => setCanSubmit(valid)}
/>Read the props inside a renderer:
fieldRegistry.register('text', ({ value, onValueChange, error, disabled }) => (
<label>
<input
disabled={disabled}
value={value ?? ''}
onChange={(e) => onValueChange?.(e.target.value)}
/>
{error && <span className="error">{[].concat(error).join(', ')}</span>}
</label>
));Forward ariaInvalid, ariaRequired and ariaDescribedBy too, and put
makeErrorId(id) on whatever element renders the message. focusFirstInvalidField
selects [aria-invalid="true"], so a renderer that drops those props makes that
helper silently do nothing. See
the recipes.
Set the built-in validators' messages once per form instead of on every field:
const form = useDynamicForm({
fields,
messages: { required: 'Bắt buộc', minLength: 'Tối thiểu {min} ký tự' },
});A message passed straight to a validator still wins, and any key omitted falls
back to the English default. setDefaultMessages(catalog) sets a process-wide
one for code calling validateFields directly. Full key list in the
core README. No locale bundles
ship — the mechanism is here, the translations are yours.
options may return a promise, and the renderer receives optionsStatus
('idle' | 'loading' | 'ready' | 'error'), optionsError and onOptionsQuery:
{
name: 'assignee',
type: 'userPicker',
options: async (data, _rootData, ctx) =>
fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal })
.then((r) => r.json()),
optionsDeps: (data) => [data.team], // reload when this changes; default []
debounceMs: 300, // collapses rapid reloads into one fetch
}Superseded requests are aborted and out-of-order responses discarded, so the list always reflects the newest request. Static and synchronous options are untouched and never enter a loading state. See the core README.
A field with fields renders as a repeatable group: data[name] becomes an array of items, each shaped by the nested fields, with "Add"/"Remove" controls rendered automatically.
const fields: FieldDescription[] = [
{
name: 'contacts',
type: 'group',
label: 'Contacts',
fields: [
{ name: 'email', type: 'text', label: 'Email' },
{ name: 'phone', type: 'text', label: 'Phone' },
],
defaultItem: { email: '', phone: '' },
keyField: 'id', // optional: stable list key instead of the array index
minItems: 1,
maxItems: 5,
},
];
<MultiFieldInput fieldDescriptions={fields} />;fieldRegistry is a process-wide singleton. To give a subtree its own renderers, create an isolated FieldRegistry and wrap the subtree in FieldRegistryProvider. Anything not wrapped keeps using the global singleton.
import {
FieldRegistry,
FieldRegistryProvider,
MultiFieldInput,
} from '@dynamic-field-kit/react';
const registry = new FieldRegistry();
registry.register('text', MyTextRenderer);
<FieldRegistryProvider registry={registry}>
<MultiFieldInput fieldDescriptions={fields} />
</FieldRegistryProvider>;Add your app's field types through module augmentation:
import '@dynamic-field-kit/core';
declare module '@dynamic-field-kit/core' {
interface FieldTypeMap {
text: string;
number: number;
}
}@dynamic-field-kit/corestays framework-agnostic and does not export React-specific JSX types.@dynamic-field-kit/reactnarrows the shared registry to React component types sofieldRegistry.get(type)can be rendered safely in TSX.MultiFieldInputfilters fields usingappearConditionand derives fields usingcomputeValue.DynamicInputrendersUnknown field type: ...when a renderer is missing.- Fields with
fieldsrender as repeatable groups instead of going throughfieldRegistry.
MIT