`FormField` with its react-hook-form wiring already done — the control's value/onChange/onBlur/ref, the label association, and the error message.

pnpm add @behivetech/forms.form-field-managed

This is the snapshot of v0.1.10, the current version, as recorded on 2026-11-10. The live page runs its examples →

registry/behivetech/forms/form-field-managed at v0.1.10, snapshotted 2026-11-10

form-field-managed.tsx
1import { type ReactNode, useId } from "react";
2import { FormField, type FormFieldProps } from "@behivetech/forms.form-field";
3import {
4 useController,
5 type Control,
6 type ControllerFieldState,
7 type ControllerRenderProps,
8 type FieldPath,
9 type FieldValues,
10 type RegisterOptions,
11} from "@behivetech/forms.form-provider";
12
13/**
14 * What the render prop receives: the wired field plus its `id`.
15 *
16 * `TName` is threaded through to `ControllerRenderProps` rather than left at its
17 * default. Without it `value` widens to the union of every field type in the
18 * form, so a call site spreading onto a `TextField` has to assert
19 * `value as string` — an assertion that keeps compiling if the field later
20 * changes type, which is the mistake the generic exists to catch.
21 */
22export type ManagedField<
23 TFieldValues extends FieldValues = FieldValues,
24 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
25> = ControllerRenderProps<TFieldValues, TName> & {
26 /** Matches the label's `htmlFor`; spread onto the control or pass explicitly */
27 id: string;
28};
29
30export interface FormFieldManagedProps<
31 TFieldValues extends FieldValues = FieldValues,
32 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
33> extends Omit<FormFieldProps, "children" | "error" | "htmlFor"> {
34 /** Field path in the form values; infers the field's value type */
35 name: TName;
36 /** Form control; falls back to the surrounding `useFormContext()` when omitted */
37 control?: Control<TFieldValues>;
38 /** Validation rules passed through to react-hook-form */
39 rules?: RegisterOptions<TFieldValues, TName>;
40 /** Renders the control from the wired field */
41 children: (
42 field: ManagedField<TFieldValues, TName>,
43 fieldState: ControllerFieldState,
44 ) => ReactNode;
45}
46
47/**
48 * `FormField` with its react-hook-form wiring already done: the control's
49 * value/onChange/onBlur/ref, the label association, and the error message.
50 *
51 * Uses `useController` rather than `register` because `register` only works on
52 * controls that render a native input. `TextField` and `Textarea` do;
53 * `Checkbox`, `Toggle` and `Select` are Radix-backed, where the ref would land
54 * on the trigger button and the change callback is `onCheckedChange` /
55 * `onValueChange`, so `register` binds nothing and the field silently keeps its
56 * default. `useController` works for all of them.
57 *
58 * The control is supplied through a render prop rather than cloned, because the
59 * prop a control listens on is the one thing that genuinely differs between
60 * them — worth seeing at the call site, and impossible to type if hidden.
61 *
62 * Pass `control` and both type parameters infer, so `field.value` is the type
63 * of that one field and spreads onto a control without a cast:
64 *
65 * ```tsx
66 * <FormFieldManaged control={methods.control} name="email" label="Email">
67 * {(field) => <TextField {...field} />}
68 * </FormFieldManaged>
69 * ```
70 *
71 * Taking `control` from context instead means naming both parameters. Supplying
72 * only the first — `<FormFieldManaged<Values> name="email">` — leaves `TName` at
73 * its default and widens `field.value` to a union of every field in the form,
74 * because TypeScript stops inferring the rest once any type argument is given
75 * explicitly:
76 *
77 * ```tsx
78 * <FormFieldManaged<Values, "email"> name="email" label="Email">
79 * {(field) => <TextField {...field} />}
80 * </FormFieldManaged>
81 * ```
82 */
83export const FormFieldManaged = <
84 TFieldValues extends FieldValues = FieldValues,
85 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
86>({
87 name,
88 control,
89 rules,
90 children,
91 ...fieldProps
92}: FormFieldManagedProps<TFieldValues, TName>) => {
93 const id = useId();
94 const { field, fieldState } = useController<TFieldValues, TName>({
95 name,
96 control,
97 rules,
98 });
99
100 return (
101 <FormField {...fieldProps} htmlFor={id} error={fieldState.error?.message}>
102 {children({ ...field, id }, fieldState)}
103 </FormField>
104 );
105};
form-field-managed.type-test.tsx
1import { useForm } from "@behivetech/forms.form-provider";
2import { FormFieldManaged } from "./form-field-managed.js";
3
4/**
5 * Compile-time coverage for the render prop's `value` narrowing.
6 *
7 * This lives outside the spec file on purpose: `tsconfig.json` excludes
8 * `**\/*.spec.*`, so nothing in the specs is type-checked and a widening
9 * regression there would surface only in a consumer's build — which is how
10 * `field.value` shipped as a union of every field type in the form. `tsc
11 * --noEmit` covers this file, so the narrowing is enforced by `check-types`.
12 *
13 * Nothing here runs; it only has to compile.
14 */
15
16interface Values {
17 email: string;
18 active: boolean;
19}
20
21/** Fails to compile if `field.value` is wider than `string`. */
22const expectString = (value: string) => value;
23
24// Both type arguments supplied: `TName` narrows `value` to that field's type.
25export const ExplicitBothArgs = () => (
26 <FormFieldManaged<Values, "email"> name="email" label="Email">
27 {(field) => <span>{expectString(field.value)}</span>}
28 </FormFieldManaged>
29);
30
31// `control` supplied: both parameters infer, nothing written by hand.
32export const InferredFromControl = () => {
33 const methods = useForm<Values>({
34 defaultValues: { email: "", active: false },
35 });
36
37 return (
38 <FormFieldManaged control={methods.control} name="email" label="Email">
39 {(field) => <span>{expectString(field.value)}</span>}
40 </FormFieldManaged>
41 );
42};
43
44// The boolean field narrows the same way, so a mismatch is still caught.
45export const NarrowsBooleanField = () => (
46 <FormFieldManaged<Values, "active"> name="active" label="Active">
47 {(field) => <input type="checkbox" checked={field.value} readOnly />}
48 </FormFieldManaged>
49);
50
51/**
52 * Supplying only `TFieldValues` leaves `TName` at its default, so `value` stays
53 * the union — TypeScript stops inferring the remaining parameters as soon as
54 * one is given explicitly. This is a language rule, not something the component
55 * can type its way around, which is why the docstring steers callers to
56 * `control` or to naming both arguments.
57 *
58 * The expected error is the guard: if a future TypeScript infers partial type
59 * arguments, this line stops erroring and the comment above needs revisiting.
60 */
61export const PartialArgsStillWiden = () => (
62 <FormFieldManaged<Values> name="email" label="Email">
63 {/* @ts-expect-error `value` is `string | boolean` when `TName` defaults */}
64 {(field) => <span>{expectString(field.value)}</span>}
65 </FormFieldManaged>
66);
index.ts
1export { FormFieldManaged } from "./form-field-managed.js";
2export type {
3 FormFieldManagedProps,
4 ManagedField,
5} from "./form-field-managed.js";
form-field-managed.example.tsx
1import { Button } from "@behivetech/atoms.button";
2import { Select } from "@behivetech/atoms.select";
3import { TextField } from "@behivetech/atoms.text-field";
4import { Toggle } from "@behivetech/atoms.toggle";
5import { Form, useForm } from "@behivetech/forms.form-provider";
6import { FormFieldManaged } from "./form-field-managed.js";
7
8interface DemoValues {
9 email: string;
10 role: string;
11 active: boolean;
12}
13
14const ROLES = [
15 { label: "Admin", value: "admin" },
16 { label: "Editor", value: "editor" },
17];
18
19/**
20 * One managed form covering both integration shapes: a native control, where
21 * the field spreads straight on, and two Radix controls, where the caller maps
22 * the one prop that differs.
23 *
24 * Each field passes `control` rather than naming type parameters. That is the
25 * shape worth copying: it infers both parameters, so `field.value` is the type
26 * of that one field and needs no cast. Reading `control` from context instead
27 * works too, but then the field type has to be named — `<FormFieldManaged<
28 * DemoValues, "email">` — because supplying one type argument stops TypeScript
29 * inferring the other.
30 */
31export const ManagedForm = () => {
32 const methods = useForm<DemoValues>({
33 defaultValues: { email: "", role: "", active: false },
34 });
35
36 return (
37 <Form methods={methods} onSubmit={(values) => console.log(values)}>
38 <FormFieldManaged
39 control={methods.control}
40 name="email"
41 label="Email"
42 hint="We only use this for sign-in"
43 required
44 rules={{ required: "Email is required" }}
45 >
46 {(field) => <TextField {...field} type="email" />}
47 </FormFieldManaged>
48
49 <FormFieldManaged
50 control={methods.control}
51 name="role"
52 label="Role"
53 rules={{ required: "Pick a role" }}
54 >
55 {(field) => (
56 <Select
57 id={field.id}
58 name={field.name}
59 options={ROLES}
60 value={field.value}
61 onValueChange={field.onChange}
62 placeholder="Pick one"
63 />
64 )}
65 </FormFieldManaged>
66
67 <FormFieldManaged control={methods.control} name="active" label="Active">
68 {(field) => (
69 <Toggle
70 id={field.id}
71 name={field.name}
72 checked={field.value}
73 onCheckedChange={field.onChange}
74 />
75 )}
76 </FormFieldManaged>
77
78 <Button type="submit" variant="primary" size="sm">
79 Save
80 </Button>
81 </Form>
82 );
83};