Custom Form Components
VEF does not require every form to stay limited to built-in fields, or to be written as one flat component.
When a form needs to be split up or a field set reused, the preferred path is to extend the existing form system rather than build a parallel one outside useForm().
Existing Extension Points
The form layer in @vef-framework-react/components exposes three composition APIs, re-exported from TanStack Form's createFormHook():
useFormContext— reads the form instance a parent component already created, so a child component can render fields without receiving the form as a prop. This is the one used most often; see Forms.withForm— binds a render function to a specific form shape ahead of time, so a large form can be split into components without losing field type-checking onname.withFieldGroup— binds a render function to a reusable slice of form values (a field group), so the same sub-form (an address, a contact block) can be mounted at different paths in different parent forms.
Common Extension Patterns
The most common extension directions are:
- combine existing fields into a business-oriented section
- wrap an existing field with more domain-specific behavior
For example, splitting a form section out with useFormContext():
function UserBaseFields() {
const { AppField } = useFormContext<UserFormValues>();
return (
<>
<AppField name="name">
{field => <field.Input label="Name" required />}
</AppField>
<AppField name="remark">
{field => <field.TextArea label="Remark" />}
</AppField>
</>
);
}
Reach for withFieldGroup instead of useFormContext() when the same field set needs to be reusable across more than one form shape — useFormContext() only works inside a form that already provides the exact type it asks for, while a field group can be mounted against any form that has a matching slice of values.
Building New Field Controls
The field.* map (field.Input, field.Select, ...) is fixed inside the framework: @vef-framework-react/components calls TanStack Form's createFormHook() once with the built-in field components. There is no registration API — an application cannot add its own entries to field.*, and the internal withFormItem wrapper that gives built-in fields their label and validation chrome is not exported.
The supported pattern is a plain controlled component wired up inside the AppField render prop. The field argument the render prop receives is not just a namespace for field.Input and friends — it is the full TanStack field API:
field.state.value— the current valuefield.handleChange(value)— write a new value (runsonChangevalidators)field.handleBlur()— mark the field as touched (runsonBlurvalidators)field.state.meta— validation state:errors,isValid,isValidating
A Worked Example
A severity picker for an alert rule. The control itself is an ordinary value / onChange component with no form dependency:
import { Button, Group } from "@vef-framework-react/components";
const LEVELS = ["low", "medium", "high"] as const;
type Severity = (typeof LEVELS)[number];
interface SeverityPickerProps {
value?: Severity | null;
onChange: (value: Severity) => void;
onBlur?: () => void;
}
function SeverityPicker({ value, onChange, onBlur }: SeverityPickerProps) {
return (
<Group>
{LEVELS.map(level => (
<Button
key={level}
type={value === level ? "primary" : "default"}
onBlur={onBlur}
onClick={() => onChange(level)}
>
{level}
</Button>
))}
</Group>
);
}
Wiring it into a form is the same AppField usage as any built-in field — validators, listeners, and Subscribe all compose unchanged:
import { useFormContext } from "@vef-framework-react/components";
import { z } from "@vef-framework-react/shared";
function AlertRuleFields() {
const { AppField } = useFormContext<AlertRuleFormValues>();
return (
<AppField
name="severity"
validators={{ onChange: z.enum(["low", "medium", "high"], "Required") }}
>
{field => (
<SeverityPicker
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
/>
)}
</AppField>
);
}
Labels and Error Display
A custom control renders without the Form.Item chrome that built-in fields get, so the shared FormItemProps (label, labelWidth, ...) documented in Form do not apply — render a label as part of the control, or alongside it. Validation state is available on the same field API:
{field => (
<>
<SeverityPicker
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
/>
{!field.state.meta.isValid && (
<Alert title={field.state.meta.errors[0]?.message} type="error" />
)}
</>
)}
When the same control plus wiring recurs across forms, extract it exactly like any other field set — a section component built on useFormContext(), or a withFieldGroup when it must mount into more than one form shape.
Practical Guidance
- Keep the
AppFieldmental model intact. - Let reuse happen at the field-composition layer instead of rebuilding form state.
- Combine existing fields first, and only add lower-level adapters when that is truly necessary.