Recetas de formularios
Los patrones completos — schema, server action, wizard y listas repetibles.
Formulario completo con zod + server action
"use client";
const schema = z.object({
name: z.string().min(1, "El nombre es requerido"),
price: z.number().min(0, "El precio no puede ser negativo"),
category: z.string().min(1, "La categoría es requerida"),
active: z.boolean(),
});
export function ProductForm() {
const [isPending, startTransition] = useTransition();
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { name: "", price: 0, category: "", active: true },
});
const onSubmit = form.handleSubmit((values) => {
startTransition(async () => {
const result = await createProductAction(values);
if (result.error) return toast.error(result.error);
toast.success(result.success);
});
});
return (
<Form {...form}>
<form onSubmit={onSubmit}>
<FormSection title="Datos del producto">
<FormTextField control={form.control} name="name" label="Nombre" required />
<FormFieldRow>
<FormCurrencyField control={form.control} name="price" label="Precio" required />
<FormSelectField control={form.control} name="category" label="Categoría" options={CATEGORIES} required />
</FormFieldRow>
<FormSwitchField control={form.control} name="active" label="Activo" />
</FormSection>
<FormButtonGroup>
<FormCancelButton onClick={() => router.back()}>Cancelar</FormCancelButton>
<FormSubmitButton loading={isPending}>Crear producto</FormSubmitButton>
</FormButtonGroup>
</form>
</Form>
);
}Wizard multipaso
const STEPS = ["Datos", "Dirección", "Confirmación"];
<FormStepIndicator steps={STEPS} currentStep={step} />
{step === 0 && <DatosSection control={form.control} />}
{step === 1 && <FormAddressField control={form.control} name="address" />}
{step === 2 && <FormValidationSummary control={form.control} />}Valida por paso con form.trigger(["name", "email"]) antes de avanzar.
Lista repetible (items de una orden)
<FormArrayField
control={form.control}
name="items"
addLabel="Agregar producto"
renderItem={(index) => (
<FormFieldRow>
<FormTextField control={form.control} name={`items.${index}.name`} label="Producto" />
<FormNumberField control={form.control} name={`items.${index}.qty`} label="Cantidad" min={1} />
</FormFieldRow>
)}
/>Campo condicional
<FormSwitchField control={form.control} name="hasDelivery" label="¿Requiere entrega?" />
<FormConditionalField control={form.control} watch="hasDelivery" condition={(v) => v === true}>
<FormAddressField control={form.control} name="deliveryAddress" label="Dirección de entrega" />
</FormConditionalField>Búsqueda async contra el servidor
<FormSearchableCombobox
control={form.control}
name="clientId"
label="Cliente"
searchAction={searchClientsAction}
placeholder="Buscar cliente…"
/>searchAction es una server action (query: string) => Promise<ComboboxOption[]> — la librería pone el debounce.