React Package

<FieldRenderer/>

Reference for the <FieldRenderer/> component

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 TOutput and TPath
  • Subscribes only to that field's mutations
  • Delegates rendering entirely to your render function
  • 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

form!
UseForm<TOutput>
The form controller instance returned by useForm(). This is required to access the form's internal state and methods for the specified field.
path!
FieldPath.Segments
The field path to the specific field within the form's data structure.
defaultValue?:
Suppliable<FieldPath.Resolve<TOutput, TPath>>
The default value to be used for the field if no initial value is set.
overrideInitialValue?:
boolean
Whether to override any initial value that was set on the form controller, if defaultValue was used to set the field's initial value.
unregisterOnUnmount?:
boolean
Whether to form.controller.unregisterField() the field from the form controller when it is unmounted.
render?:
(params: RenderParams<TOutput, FieldPath.Resolve<TOutput, TPath>>) => ReactNode
The render function that will be called to render the field UI.

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 FormField instance 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 defaultValue if 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 is field.stringPath
  • value, which is field.value
  • ref, which is the React ref bound to the underlying DOM element via field.bindElement(...)
  • onChange, which forwards the < to field.setValue(...) and marks the field as touched and dirty
  • onFocus, which calls field.touch()
  • onBlur, which conditionally triggers field validation based on the active validation mode (respecting form.hookConfigs)
If you need transformation logic, use 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:

  • validateMode
  • revalidateMode
  • 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"
          }
        />
      )}
    />
  );
}
Copyright ยฉ 2026