<FieldRenderer/>
Overview
export interface RenderParams<TOutput extends object, TValue> {
fieldProps: {
ref: Ref<any | null>;
name: string;
value: DeepReadonly<TValue> | undefined;
onChange: (event: ChangeEvent<EventTarget> | TValue) => void;
onFocus: () => void;
onBlur: () => void;
};
field: FormField<TOutput, TValue>;
form: UseForm<TOutput>;
}
export interface FieldRendererProps<
TOutput extends object,
TPath extends FieldPath.Segments,
> {
form: UseForm<TOutput>;
path: TPath;
defaultValue?: Suppliable<FieldPath.Resolve<TOutput, TPath>>;
overrideInitialValue?: boolean;
unregisterOnUnmount?: boolean;
render: (
params: RenderParams<TOutput, FieldPath.Resolve<TOutput, TPath>>,
) => ReactNode;
}
export function FieldRenderer<
TOutput extends object,
const TPath extends FieldPath.Segments,
>(props: FieldRendererProps<TOutput, TPath>);
- TOutputrepresents the final validated shape of your form data. It defines the full object structure that the form is expected to produce after successful validation and submission.
- TPathrepresents the strongly-typed path (as segment tuples) pointing to a specific leaf inside
TOutput.
FieldRenderer is the React field binding layer of Goodie Forms.
It connects a single field (resolved via a strongly-typed path) to your UI โ without introducing implicit magic, hidden state, or uncontrolled behavior.FieldRenderer does these:
- Registers a field inside the
FormController - Resolves its type directly from
TOutputandTPath - Subscribes only to that field's mutations
- Delegates rendering entirely to your
renderfunction - Keeps everything fully type-safe
It is intentionally minimal.
- You own the UI.
- You control when it mounts.
- You decide how it renders.
Component Props
useForm(). This is required to access the form's internal state and methods for the specified field.defaultValue was used to set the field's initial value.form.controller.unregisterField() the field from the form controller when it is unmounted.Render Delegate
FieldRenderer does not render anything by itself.
Instead, it delegates rendering entirely to your render function.
This keeps the library headless and lets you decide how your UI should behave.
<FieldRenderer
form={form}
path={form.path.of("name")}
defaultValue="foo"
render={({ fieldProps, field, form }) => (
<input
{...fieldProps}
disabled={form.controller.isSubmitting}
type="text"
placeholder="John"
className={field.issues.length !== 0 ? "text-red-500" : "text-green-500"}
/>
)}
/>;
๐ญ Why a Render Function?
This pattern ensures:
- โ No implicit prop injection
- โ No hidden wrapping components
- โ No opinionated input abstraction
- Full compatibility with any design system
You are not forced into:
- Controlled-only inputs
- Specific UI libraries
- Special wrapper components
Instead, you explicitly decide how the field integrates with your UI.
๐ญ Rendering Strategies
If you want the fastest integration โ use fieldProps.
<FieldRenderer
form={form}
path={form.path.of("email")}
render={({ fieldProps }) => (
<input {...fieldProps} type="email" placeholder="john@example.com" />
)}
/>;
If you want maximum control โ ignore destructuring fieldProps and wire everything from field.
<FieldRenderer
form={form}
path={form.path.of("age")}
render={({ field }) => (
<input
type="number"
value={field.value ?? ""}
onChange={(e) => {
const parsed = Number(e.target.value);
field.setValue(Number.isNaN(parsed) ? undefined : parsed);
}}
onFocus={() => field.touch()}
onBlur={() => field.validate()}
/>
)}
/>;
If you want a hybrid approach โ mix fieldProps and field as desired.
<FieldRenderer
form={form}
path={form.path.of("tags")}
defaultValue={() => []}
render={({ fieldProps, field }) => (
<div {...fieldProps}>
<ul>
{fieldProps.value.map((tag: string, index: number) => (
<li key={index}>{tag}</li>
))}
</ul>
<button
type="button"
onClick={() => {
field.modifyValue((value) => {
value?.push(`tag-${current.length + 1}`);
});
}}
>
Add Tag
</button>
</div>
)}
/>;
Since FieldRenderer fully delegates rendering to your render function, you are free to use any component without constraints.
<FieldRenderer
form={form}
path={form.path.of("country")}
render={({ field }) => (
<CustomSelect
selected={field.value}
options={countryOptions}
onSelect={(option) => field.setValue(option.code)}
onClose={() => field.touch()}
/>
)}
/>;
All those approaches above are first-class citizens.
FieldRenderer simply bridges a single field's event stream into React โ nothing more.
Behavior
FieldRenderer is intentionally small.
- โ It does not introduce hidden state.
- โ It does not wrap your input.
- โ It does not own your UI.
- It simply connects a single
FormFieldinstance to React โ and gets out of the way.
๐ง 1. Resolves & Registers the Field
It calls useFormField() internally.
useFormField(form, path, { defaultValue, overrideInitialValue });
This allows the FieldRenderer to:
- Resolve the field using the strongly-typed path
- Register it inside the
FormController - Apply
defaultValueif needed - Optionally override initial data (controlled by
overrideInitialValue)
No extra abstraction layer is created.
You are interacting with the real FormField instance.
๐ง 2. Binds the DOM Element (Explicitly)
A ref is created and passed via fieldProps.ref.
field.bindElement(elementRef.current!);
This allows the controller to:
- Associate the field with a specific DOM element
- Auto-focus on correct DOM element on form submission
Nothing is auto-detected. Nothing is queried from the DOM. Binding is explicit and lifecycle-aware.
๐ง 3. Provides a Minimal fieldProps Adapter
fieldProps is a very thin adapter โ nothing more. It exposes:
name, which isfield.stringPathvalue, which isfield.valueref, which is the React ref bound to the underlying DOM element viafield.bindElement(...)onChange, which forwards the < tofield.setValue(...)and marks the field as touched and dirtyonFocus, which callsfield.touch()onBlur, which conditionally triggers field validation based on the active validation mode (respectingform.hookConfigs)
fielddirectly instead.
Validation will still run automatically once the field value changes.<FieldRenderer
form={form}
path={form.path.of("age")}
render={({ field }) => (
<input
type="number"
value={field.value ?? ""}
onChange={(e) => {
const parsed = Number(e.target.value);
field.setValue(Number.isNaN(parsed) ? undefined : parsed);
// ^ it'll still trigger validation
}}
onBlur={() => field.markAsTouched()}
/>
)}
/>;
๐ง 4. Validation Behavior (Respecting Modes)
Validation is triggered based on:
validateModerevalidateMode- Whether the form has attempted submission
On blur:
if (
field.issues.length !== 0 ||
currentValidateMode === "onBlur" ||
currentValidateMode === "onChange"
) {
controller.validateField(path);
}
On value change (via controller events):
- It listens to
"fieldValueChanged" - Checks whether the change affects this field or its descendants
- Triggers validation only when appropriate
This means:
- โ No global re-renders
- โ No blanket revalidation
- Only scoped field-level validation
๐ง 5. Scoped React Subscription
The component subscribes only to relevant controller events.
- โ Does not subscribe the entire form
- โ Does not trigger parent re-renders
- โ Does not depend on React context reactivity
It reacts strictly to mutations that affect:
- This field
- Or its descendants (for nested structures)
๐ง 6. Optional Cleanup
On unmount:
if (unregisterOnUnmount) {
controller.unregisterField(path);
}
By default, fields are not destroyed.This enables:
- Multi-step forms
- Tabbed layouts
- Conditionally mounted sections
- Virtualized field lists
State persists unless you explicitly opt out.
Examples
๐ Basic Text Field
A minimal text input with submission-aware disabling.
<FieldRenderer
form={form}
path={form.path.of("name")}
render={({ fieldProps, form }) => (
<input
{...fieldProps}
type="text"
placeholder="John Doe"
disabled={form.controller.isSubmitting}
/>
)}
/>;
๐ Checkbox Field (Boolean)
Direct value binding for boolean fields.
<FieldRenderer
form={form}
path={form.path.of("acceptTerms")}
defaultValue={false}
render={({ field }) => (
<label>
<input
type="checkbox"
checked={!!field.value}
onChange={(e) => field.setValue(e.target.checked)}
onFocus={() => field.touch()}
/>
I agree to the terms
</label>
)}
/>;
๐ Dynamic Tag List (Array Field)
Hybrid approach using structural mutation.
<FieldRenderer
form={form}
path={form.path.of("tags")}
defaultValue={() => []}
render={({ fieldProps, field }) => (
<div {...fieldProps}>
<ul>
{fieldProps.value.map((tag: string, index: number) => (
<li key={index}>{tag}</li>
))}
</ul>
<button
type="button"
onClick={() => {
field.modifyValue((value) => {
value?.push(`tag-${current.length + 1}`);
});
}}
>
Add Tag
</button>
</div>
)}
/>;
๐ Slider with Derived Display
A non-text input with derived UI.
<FieldRenderer
form={form}
path={form.path.of("volume")}
defaultValue={50}
render={({ fieldProps }) => (
<div>
<input
{...fieldProps}
type="range"
min={0}
max={100}
value={fieldProps.value ?? 0}
onChange={(e) => fieldProps.onChange(Number(e.target.value))}
/>
<span>{fieldProps.value ?? 0}%</span>
</div>
)}
/>;
๐ Conditionally Derived Field
Updating a field based on another fieldโs value.
function SlugField() {
const title = useFieldValue(form, form.path.of("title"));
return (
<FieldRenderer
form={form}
path={form.path.of("slug")}
render={({ field }) => (
<input
value={field.value ?? ""}
onChange={(e) =>
field.setValue(e.target.value.toLowerCase().replace(/\s+/g, "-"))
}
placeholder={
title
? title.toLowerCase().replace(/\s+/g, "-")
: "auto-generated-slug"
}
/>
)}
/>
);
}