Loading...
Loading...
React Hook Form 7 + Zod patterns para SmartPocket. Usar al crear/editar forms, validación de schemas, submissions, errores de API (field-level + global), integración con TanStack Query mutations, useFormErrorHandler hook, activeMutation pattern, values vs defaultValues, form.reset() con/sin parámetros, coercion manual de números. Sistema de 3 capas de errores, estado derivado con watch(). Incluye anti-patterns eliminados y quick reference tables.
npx skill4agent add chinomartinez/smartpocket frontend-formsuseFormErrorHandleractiveMutationvalueshandleOpenChangewatch()// entitySchema.ts
import { z } from "zod";
export const entitySchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name too long"),
amount: z.number("Must be a number").min(0, "Must be positive"),
categoryId: z.number("Must select a category").int().positive(),
});
// Inferir tipo del schema (type-safe!)
export type EntityFormValues = z.infer<typeof entitySchema>;import { useCreateEntity, useUpdateEntity } from "./useEntities";
function EntityFormModal({ entity, open, onOpenChange }) {
const mode: "create" | "edit" = entity ? "edit" : "create";
const createMutation = useCreateEntity();
const updateMutation = useUpdateEntity();
// activeMutation simplifica lógica de create/edit
const activeMutation = mode === "create" ? createMutation : updateMutation;
// Estados derivados de una sola fuente
const isSubmitting = activeMutation.isPending;
const apiError = activeMutation.error as ApiError | null;
}const DEFAULT_VALUES: EntityFormValues = {
name: "",
amount: 0,
categoryId: 0,
};
const form = useForm<EntityFormValues>({
resolver: zodResolver(entitySchema),
// values (no defaultValues) - sincroniza automáticamente cuando entity cambia
values:
mode === "edit" && entity
? { name: entity.name, amount: entity.amount, categoryId: entity.categoryId }
: DEFAULT_VALUES,
});useEffectimport { useFormErrorHandler } from "@/hooks/useFormErrorHandler";
const handleFormError = useFormErrorHandler(form);const form = useForm<MyFormValues>({...});
const handleFormError = useFormErrorHandler(form);
createMutation.mutate(payload, {
onError: handleFormError, // ← Una línea
});propertyName<FormMessage />const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
activeMutation.reset(); // Limpia error automáticamente
form.reset(DEFAULT_VALUES);
}
onOpenChange(isOpen);
};
const onSubmit = (data: EntityFormValues) => {
if (mode === "create") {
createMutation.mutate(data, {
onSuccess: () => handleOpenChange(false),
onError: handleFormError, // ← Hook inyecta errores
});
} else if (mode === "edit" && entity) {
updateMutation.mutate(
{ id: entity.id, data },
{
onSuccess: () => handleOpenChange(false),
onError: handleFormError,
},
);
}
};useState<ApiError>setApiError(null)useEffectreturn (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Errores globales (sin propertyName) */}
{apiError && <ErrorAlert error={apiError} />}
{/* Campo con errores automáticos */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage /> {/* Errores de Zod + API automáticos */}
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : mode === "create" ? "Create" : "Save"}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);propertyName<FormMessage />propertyName<ErrorAlert />propertyName// ✅ Pattern correcto de SmartPocket
// 1. Schema: z.number() directo
const schema = z.object({
amount: z.number("Must be a number").positive("Must be greater than 0"),
accountId: z.number("Must select an account").int().positive(),
});
// 2. Component: conversión manual en onChange
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormControl>
<Input
type="number"
step="0.01"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value))}
/>
</FormControl>
)}
/>z.coerce.number()required_errorinvalid_type_error// Schema
categoryId: z.number("Must select a category").int().positive();
// Component
<Select
onValueChange={(value) => field.onChange(parseInt(value))}
{...field}
/>| Escenario | Usar | Razón |
|---|---|---|
| Form create-only (valores estáticos) | | Se establece una vez al montar |
| Form create/edit con mode toggle | | Sincroniza automáticamente con props |
| Form con datos externos dinámicos | | Reacciona a cambios de entity/data |
| Form con valores hardcodeados | | No necesita reactivity |
// defaultValues - valores estáticos (NO sincroniza con props)
const form = useForm({
defaultValues: { name: "" },
});
// values - sincroniza reactivamente cuando entity cambia
const form = useForm({
values: mode === "edit" && entity ? { name: entity.name } : DEFAULT_VALUES,
});| Uso | Efecto |
|---|---|
| Resetea a defaultValues/values originales |
| Resetea a valores específicos |
| Garantiza estado limpio (recomendado al cerrar modals) |
// Sin parámetros - vuelve a values/defaultValues
form.reset();
// Con parámetros - establece valores específicos
form.reset({ name: "", amount: 0 });
// Al cerrar modal - garantiza limpieza completa
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
activeMutation.reset();
form.reset(DEFAULT_VALUES); // ← Recomendado
}
onOpenChange(isOpen);
};useStatewatch()// ❌ Antes - estado duplicado
const [selectedType, setSelectedType] = useState<boolean>(false);
<Button onClick={() => {
field.onChange(true);
setSelectedType(true); // ← Sincronización manual
}} />
// ✅ Después - derivado de form
const selectedType = form.watch("isIncome");
<Button onClick={() => field.onChange(true)} />function EditEntityForm({ entityId }: { entityId: number }) {
const { data: entity } = useEntity(entityId);
const form = useForm<EntityFormValues>({
resolver: zodResolver(entitySchema),
// values (no defaultValues) - sincroniza cuando entity cambia
values: entity ? { name: entity.name, amount: entity.amount } : DEFAULT_VALUES,
});
}const schema = z.object({
items: z.array(
z.object({
description: z.string().min(1),
amount: z.number("Must be a number").positive(),
})
).min(1, "At least one item required"),
});
function DynamicForm() {
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
});
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<FormField name={`items.${index}.description`} /* ... */ />
<FormField name={`items.${index}.amount`} /* ... */ />
<Button onClick={() => remove(index)}>Remove</Button>
</div>
))}
<Button onClick={() => append({ description: "", amount: 0 })}>
Add Item
</Button>
</>
);
}const schema = z
.object({
type: z.enum(["savings", "credit"]),
creditLimit: z.number("Must be a number").optional(),
})
.refine(
(data) => {
// Si type = credit, creditLimit es requerido
if (data.type === "credit") {
return data.creditLimit !== undefined && data.creditLimit > 0;
}
return true;
},
{
message: "Credit limit required for credit accounts",
path: ["creditLimit"], // Error aparece en este campo
},
);function ConditionalForm() {
const accountType = form.watch("type");
return (
<>
<FormField name="type" /* ... */ />
{/* Mostrar solo si type = credit */}
{accountType === "credit" && (
<FormField name="creditLimit" /* ... */ />
)}
</>
);
}// ❌ MAL - TanStack Query ya maneja estado
const [apiError, setApiError] = useState<ApiError | null>(null);
const onSubmit = (data) => {
setApiError(null);
createMutation.mutate(data, { onError: (error) => setApiError(error) });
};
// ✅ BIEN - usar activeMutation.error
const activeMutation = mode === "create" ? createMutation : updateMutation;
const apiError = activeMutation.error as ApiError | null;
createMutation.mutate(data, { onError: handleFormError });// ❌ MAL - sincronización manual
useEffect(() => {
if (mode === "edit" && entity) form.reset({ name: entity.name });
}, [mode, entity, form]);
// ✅ BIEN - sincronización automática
const form = useForm({
values: mode === "edit" && entity ? { name: entity.name } : DEFAULT_VALUES,
});// ❌ MAL - limpieza manual
useEffect(() => {
if (open) setApiError(null);
}, [open]);
// ✅ BIEN - limpieza centralizada en handleOpenChange
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
activeMutation.reset(); // Limpia error automáticamente
form.reset(DEFAULT_VALUES);
}
onOpenChange(isOpen);
};// ❌ MAL - helper manual en cada form
const getFieldErrors = (name: string) =>
apiError?.errors?.filter((e) => e.propertyName === name).map((e) => e.message) || [];
// ✅ BIEN - useFormErrorHandler automático
const handleFormError = useFormErrorHandler(form);
createMutation.mutate(data, { onError: handleFormError });
// Display automático con <FormMessage />// ❌ MAL - lógica condicional duplicada
const isLoading = createMutation.isPending || updateMutation.isPending;
if (!open) {
createMutation.reset();
updateMutation.reset();
}
// ✅ BIEN - activeMutation pattern
const activeMutation = mode === "create" ? createMutation : updateMutation;
const isSubmitting = activeMutation.isPending;
if (!open) activeMutation.reset();// ❌ MAL - estado duplicado + sincronización manual
const [selectedType, setSelectedType] = useState(false);
<Button onClick={() => { field.onChange(true); setSelectedType(true); }} />
// ✅ BIEN - derivado de form state
const selectedType = form.watch("isIncome");
<Button onClick={() => field.onChange(true)} />// ❌ MAL - incompatible con project pattern
amount: z.coerce.number().min(0);
// ✅ BIEN - z.number() + conversión manual
amount: z.number("Must be a number").min(0);
<Input type="number" {...field}
onChange={(e) => field.onChange(parseFloat(e.target.value))} />// ❌ MAL - confiar solo en Zod (cliente puede bypassear)
const onSubmit = (data) => {
createMutation.mutate(data);
};
// ✅ BIEN - backend SIEMPRE valida (source of truth)
const onSubmit = (data) => {
createMutation.mutate(data, { onError: handleFormError });
};// ❌ MAL - valores persisten
const onSubmit = (data) => {
createMutation.mutate(data);
};
// ✅ BIEN - reset en onSuccess
createMutation.mutate(data, {
onSuccess: () => handleOpenChange(false), // Incluye form.reset()
});// ❌ MAL - uncontrolled inputs warning
const form = useForm<FormValues>({
/* Falta defaultValues */
});
// ✅ BIEN - siempre definir
const form = useForm<FormValues>({ defaultValues: { name: "", amount: 0 } });// ❌ MAL - permite múltiples submits
<Button type="submit">Create</Button>
// ✅ BIEN - deshabilitar durante pending
<Button type="submit" disabled={activeMutation.isPending}>
{activeMutation.isPending ? "Saving..." : "Create"}
</Button>| Problema | Causa | Solución |
|---|---|---|
| "A component is changing an uncontrolled input" | Falta | Definir |
| Validación no ejecuta | Falta | Agregar |
| Edit form no pre-popula | Usar | Cambiar a |
| Números se guardan como strings | Falta conversión en | Agregar |
| Form no limpia después de submit | Falta | Llamar |
| Errores de API no aparecen | No usar | Agregar |
| useEffect ejecuta infinitamente en edit | Dependencias incorrectas con | Cambiar a |
| Errores duplicados (toast + inline) | Sistema de 3 capas mal configurado | Verificar main.tsx filtra |
| Estado derivado desincronizado | Usar | Cambiar a |
| Reset no funciona al cerrar modal | No pasar valores a | Usar |