Skip to main content

Component Mapper

The componentMapper lets you replace the default field component for any FieldType with your own React component. Use it to integrate design systems, third-party inputs, or create entirely custom behaviours.

Basic usage

import { FormRenderer } from "@schema-forms-data/renderer";
import { FieldType } from "@schema-forms-data/core";
import { MyTextField, MyDatePicker } from "./my-components";

<FormRenderer
schema={schema}
componentMapper={{
[FieldType.TEXTO]: MyTextField,
[FieldType.DATE]: MyDatePicker,
}}
onComplete={handleComplete}
/>;

Any tipo found in componentMapper will be rendered with the mapped component instead of the default widget.

FieldComponentProps

Every custom component receives these props:

interface FieldComponentProps {
name: string; // identificador do campo (FormField.nome)
control: Control; // Control do react-hook-form do formulário pai
field: FormField; // FormField completamente resolvido (após resolveProps + opcoesFromVar)
}

Creating a custom component

Use useController from react-hook-form to connect the input:

import React from "react";
import { useController } from "react-hook-form";
import type { FieldComponentProps } from "@schema-forms-data/renderer";

export function MyTextField({ name, control, field }: FieldComponentProps) {
const {
field: { value, onChange, onBlur, ref },
fieldState: { error },
} = useController({ name, control });

return (
<div className="my-field">
<label htmlFor={name}>{field.label}</label>
{field.hint && <p className="hint">{field.hint}</p>}
<input
id={name}
ref={ref}
value={(value as string) ?? ""}
onChange={onChange}
onBlur={onBlur}
placeholder={field.placeholder}
disabled={field.isDisabled}
readOnly={field.isReadOnly}
aria-invalid={!!error}
/>
{error && <span className="error">{error.message}</span>}
</div>
);
}

SELECT with options

import { useController } from "react-hook-form";
import type { FieldComponentProps } from "@schema-forms-data/renderer";

export function MySelect({ name, control, field }: FieldComponentProps) {
const {
field: rhf,
fieldState: { error },
} = useController({ name, control });

return (
<div>
<label>{field.label}</label>
<select {...rhf} value={(rhf.value as string) ?? ""}>
<option value="">Select…</option>
{field.opcoes?.map((opt) => (
<option key={opt.valor} value={opt.valor} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
{error && <p className="error">{error.message}</p>}
</div>
);
}

Component with visible validation state

import { useController } from "react-hook-form";
import { useField } from "@schema-forms-data/renderer";
import type { FieldComponentProps } from "@schema-forms-data/renderer";

export function MyEmailInput({ name, control, field }: FieldComponentProps) {
const {
field: rhf,
fieldState: { error },
} = useController({ name, control });
const { warning, touched } = useField(name);

return (
<div
className={`field ${error ? "error" : ""} ${warning && touched ? "warning" : ""}`}
>
<label>{field.label}</label>
<input
type="email"
{...rhf}
value={(rhf.value as string) ?? ""}
placeholder={field.placeholder}
/>
{error && <span className="error-msg">{error.message}</span>}
{!error && warning && touched && (
<span className="warning-msg">{warning}</span>
)}
</div>
);
}

Integrating with Radix UI

import * as Select from "@radix-ui/react-select";
import { useController } from "react-hook-form";
import type { FieldComponentProps } from "@schema-forms-data/renderer";

export function RadixSelect({ name, control, field }: FieldComponentProps) {
const { field: rhf } = useController({ name, control });

return (
<Select.Root
value={(rhf.value as string) ?? ""}
onValueChange={rhf.onChange}
>
<Select.Trigger>
<Select.Value placeholder={field.placeholder ?? "Select…"} />
</Select.Trigger>
<Select.Content>
{field.opcoes?.map((opt) => (
<Select.Item
key={opt.valor}
value={opt.valor}
disabled={opt.disabled}
>
{opt.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
}

Important notes

  • The field received is the already-resolved FormField — options loaded via opcoesFromVar, props resolved via fieldResolvers. No manual resolution needed.
  • Native validation (obrigatorio, validacao) continues to work normally for custom components.
  • Custom validators (validate[], warn[]) also continue working — they operate on the field value, not on the component.

Options from `opcoesFromVar` are already resolved and merged into `field.opcoes` at render time. Dynamic options from `opcoesFromVar` take **precedence over static `opcoes`** — static options serve as a fallback only when the `externalData` key is absent or not an array.

## Custom field types (not in FieldType enum)

You can introduce entirely new `tipo` strings beyond the built-in `FieldType` enum.

1. **Schema** — use any string as `tipo`:

```json
{
"nome": "rut",
"tipo": "rut_chile",
"titulo": "RUT",
"ordem": 1,
"obrigatorio": true
}
  1. Mapper — register the new key:
import { FormRenderer } from "@schema-forms-data/renderer";
import { RutInput } from "./RutInput";

<FormRenderer
schema={schema}
componentMapper={{
rut_chile: RutInput,
}}
onComplete={handleComplete}
/>;

Using hooks inside custom components

Because custom components are rendered inside the form tree, all hooks are available:

import { useField, useFormApi } from "@schema-forms-data/renderer";
import type { FieldComponentProps } from "@schema-forms-data/renderer";

export function SmartEmailInput({ name, control, field }: FieldComponentProps) {
const { value, error, warning } = useField(name);
const { setValue } = useFormApi();

return (
<div>
<input
value={(value as string) ?? ""}
onChange={(e) => setValue(name, e.target.value)}
/>
{warning && <p className="warning">{warning}</p>}
{error && <p className="error">{error}</p>}
</div>
);
}

Combining componentMapper with validatorMapper

Custom rendering and custom validation work independently — combine them freely:

<FormRenderer
schema={schema}
componentMapper={{
[FieldType.TEXTO]: MyTextField,
[FieldType.SELECT]: MySelect,
}}
validatorMapper={{
uniqueEmail: async (value) => {
const taken = await api.emailExists(String(value));
return taken ? "E-mail already registered" : undefined;
},
}}
onComplete={handleComplete}
/>

Precedence

When componentMapper contains a key that matches a field's tipo:

  1. The mapped component is used — the default renderer is skipped entirely
  2. resolveProps / resolvePropsKey still runs before the component receives field
  3. Conditionals, validate[], and warn[] still apply normally