frontend-forms
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSmartPocket - Forms (React Hook Form + Zod)
SmartPocket - 表单方案(React Hook Form + Zod)
Patrones modernos (2026) de formularios con React Hook Form 7.71.1 + Zod 4.3.6 para SmartPocket React app.
适用于SmartPocket React应用的2026年现代表单模式,基于React Hook Form 7.71.1 + Zod 4.3.6。
When to Use This Skill
何时使用此方案
- Crear/editar forms (create, edit, search)
- Validar schemas con Zod
- Inyectar errores de API en fields automáticamente
- Integrar forms con TanStack Query mutations
- Manejar create/edit modes en un solo componente
- Type-safe form values con z.infer
- Number field coercion (SmartPocket specific pattern)
- Sistema de 3 capas de error display
- Estado derivado con watch() vs useState
- 创建/编辑表单(创建、编辑、搜索场景)
- 使用Zod进行Schema校验
- 自动将API错误注入表单字段
- 集成表单与TanStack Query mutations
- 在单个组件中处理创建/编辑模式
- 通过z.infer实现类型安全的表单值
- 数字字段转换(SmartPocket专属模式)
- 三层错误展示系统
- 基于watch()对比useState的派生状态
Modern Form Pattern (2026)
2026年现代表单模式
Overview: Patrón estandarizado que elimina ~40 líneas de boilerplate por form.
Key patterns:
- ✅ - inyección automática de errores API en RHF
useFormErrorHandler - ✅ - simplifica manejo de create/edit modes
activeMutation - ✅ (no defaultValues+useEffect) - sincronización reactiva de edit data
values - ✅ Sistema 3 capas - field-level → inline → toast (no duplicación)
- ✅ unificado - reset centralizado
handleOpenChange - ✅ Estado derivado con - elimina useState duplicado
watch()
概述: 标准化模式,每个表单可减少约40行样板代码。
核心模式:
- ✅ - 自动将API错误注入React Hook Form
useFormErrorHandler - ✅ - 简化创建/编辑模式的处理逻辑
activeMutation - ✅ (而非defaultValues+useEffect)- 编辑数据的响应式同步
values - ✅ 三层系统 - 字段级 → 行内 → 提示框(无重复展示)
- ✅ 统一的- 集中式重置
handleOpenChange - ✅ 基于的派生状态 - 消除重复的useState
watch()
Step-by-Step: Modern Form Pattern
分步指南:现代表单模式
Step 1: Schema Zod
步骤1:Zod Schema定义
Schema define estructura + validación. SIEMPRE crear schema antes del componente.
typescript
// 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>;Schema定义结构与校验规则。务必在组件之前创建Schema。
typescript
// 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(),
});
// 从Schema推断类型(类型安全!)
export type EntityFormValues = z.infer<typeof entitySchema>;Step 2: Setup mutations + activeMutation
步骤2:配置mutations与activeMutation
typescript
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;
}Beneficios:
- Una sola abstracción para ambos modos
- Un solo lugar para verificar estado, error, y reset
- Elimina lógica condicional duplicada
typescript
import { useCreateEntity, useUpdateEntity } from "./useEntities";
function EntityFormModal({ entity, open, onOpenChange }) {
const mode: "create" | "edit" = entity ? "edit" : "create";
const createMutation = useCreateEntity();
const updateMutation = useUpdateEntity();
// activeMutation简化创建/编辑模式逻辑
const activeMutation = mode === "create" ? createMutation : updateMutation;
// 单一数据源的派生状态
const isSubmitting = activeMutation.isPending;
const apiError = activeMutation.error as ApiError | null;
}优势:
- 两种模式共用一个抽象层
- 统一的状态、错误与重置入口
- 消除重复的条件逻辑
Step 3: Form setup con values (reactive sync)
步骤3:使用values配置表单(响应式同步)
typescript
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,
});Beneficio: Elimina manual para sincronizar edit data.
useEffecttypescript
const DEFAULT_VALUES: EntityFormValues = {
name: "",
amount: 0,
categoryId: 0,
};
const form = useForm<EntityFormValues>({
resolver: zodResolver(entitySchema),
// 使用values(而非defaultValues)- 当entity变化时自动同步
values:
mode === "edit" && entity
? { name: entity.name, amount: entity.amount, categoryId: entity.categoryId }
: DEFAULT_VALUES,
});优势: 无需手动编写useEffect来同步编辑数据。
Step 4: useFormErrorHandler hook
步骤4:useFormErrorHandler钩子
typescript
import { useFormErrorHandler } from "@/hooks/useFormErrorHandler";
const handleFormError = useFormErrorHandler(form);Uso:
typescript
const form = useForm<MyFormValues>({...});
const handleFormError = useFormErrorHandler(form);
createMutation.mutate(payload, {
onError: handleFormError, // ← Una línea
});Qué hace:
- Inyecta errores de API automáticamente en RHF
- Mapeo case-insensitive: (backend PascalCase) → campo (camelCase)
propertyName - Errores aparecen bajo el campo via
<FormMessage />
typescript
import { useFormErrorHandler } from "@/hooks/useFormErrorHandler";
const handleFormError = useFormErrorHandler(form);用法:
typescript
const form = useForm<MyFormValues>({...});
const handleFormError = useFormErrorHandler(form);
createMutation.mutate(payload, {
onError: handleFormError, // ← 仅需一行
});功能:
- 自动将API错误注入React Hook Form
- 不区分大小写映射:后端的(大驼峰)→ 表单字段(小驼峰)
propertyName - 错误通过展示在对应字段下方
<FormMessage />
Step 5: Handlers simplificados
步骤5:简化的处理函数
typescript
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,
},
);
}
};Elimina:
- ❌ +
useState<ApiError>setApiError(null) - ❌ para limpiar errores
useEffect - ❌ Reset manual en múltiples lugares
typescript
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
activeMutation.reset(); // 自动清除错误
form.reset(DEFAULT_VALUES);
}
onOpenChange(isOpen);
};
const onSubmit = (data: EntityFormValues) => {
if (mode === "create") {
createMutation.mutate(data, {
onSuccess: () => handleOpenChange(false),
onError: handleFormError, // ← 钩子自动注入错误
});
} else if (mode === "edit" && entity) {
updateMutation.mutate(
{ id: entity.id, data },
{
onSuccess: () => handleOpenChange(false),
onError: handleFormError,
},
);
}
};消除的冗余代码:
- ❌ +
useState<ApiError>setApiError(null) - ❌ 用于清除错误的useEffect
- ❌ 多处手动重置逻辑
Step 6: Render con error display
步骤6:带错误展示的渲染
typescript
return (
<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>
);Sistema de errores (3 capas):
- Error con →
propertyNamebajo el campo<FormMessage /> - Error sin →
propertyNameinline arriba del form<ErrorAlert /> - Toast automático → Solo si NO hay (main.tsx)
propertyName
Resultado: Usuario ve error una vez, en el lugar apropiado.
typescript
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* 全局错误(无propertyName) */}
{apiError && <ErrorAlert error={apiError} />}
{/* 自动展示错误的字段 */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage /> {/* Zod校验错误+API错误自动展示 */}
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : mode === "create" ? "Create" : "Save"}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);错误系统(三层):
- 带的错误 → 展示在字段下方的
propertyName<FormMessage /> - 无的错误 → 展示在表单上方的
propertyName<ErrorAlert /> - 自动提示框 → 仅当错误无时触发(在main.tsx中配置)
propertyName
效果: 用户仅在合适位置看到一次错误提示。
SmartPocket Pattern: Number Fields
SmartPocket专属模式:数字字段
⚠️ IMPORTANTE: NO usar z.coerce.number() en este proyecto
⚠️ 重要提示:本项目禁止使用z.coerce.number()
SmartPocket usa conversión MANUAL, NO z.coerce:
typescript
// ✅ 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>
)}
/>¿Por qué NO z.coerce.number()?
- API incompatible: NO acepta
z.coerce.number()nirequired_errorpropsinvalid_type_error - Consistencia: Todo el proyecto usa este pattern
- Control explícito: Conversión visible en component, más fácil debuggear
Para integers (IDs):
typescript
// Schema
categoryId: z.number("Must select a category").int().positive();
// Component
<Select
onValueChange={(value) => field.onChange(parseInt(value))}
{...field}
/>SmartPocket采用手动转换,而非z.coerce:
typescript
// ✅ SmartPocket正确模式
// 1. Schema:直接使用z.number()
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. 组件:在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()?
- API不兼容: 不支持
z.coerce.number()和required_error属性invalid_type_error - 一致性: 整个项目统一使用此模式
- 显式控制: 转换逻辑在组件中可见,便于调试
针对整数(如ID):
typescript
// Schema
categoryId: z.number("Must select a category").int().positive();
// Component
<Select
onValueChange={(value) => field.onChange(parseInt(value))}
{...field}
/>Quick Reference
快速参考
values vs defaultValues
values vs defaultValues
| 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 |
typescript
// 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,
});| 场景 | 使用方式 | 原因 |
|---|---|---|
| 仅创建场景的表单(静态值) | | 组件挂载时一次性设置 |
| 支持创建/编辑切换的表单 | | 自动与props同步 |
| 包含动态外部数据的表单 | | 响应entity/data的变化 |
| 包含硬编码值的表单 | | 无需响应式更新 |
typescript
// defaultValues - 静态值(不与props同步)
const form = useForm({
defaultValues: { name: "" },
});
// values - 当entity变化时自动响应式同步
const form = useForm({
values: mode === "edit" && entity ? { name: entity.name } : DEFAULT_VALUES,
});form.reset() - Con/Sin Parámetros
form.reset() - 带/不带参数
| Uso | Efecto |
|---|---|
| Resetea a defaultValues/values originales |
| Resetea a valores específicos |
| Garantiza estado limpio (recomendado al cerrar modals) |
typescript
// 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);
};| 用法 | 效果 |
|---|---|
| 重置为原始的defaultValues/values |
| 重置为指定值 |
| 确保表单状态干净(关闭弹窗时推荐使用) |
typescript
// 不带参数 - 恢复为values/defaultValues
form.reset();
// 带参数 - 设置指定值
form.reset({ name: "", amount: 0 });
// 关闭弹窗时 - 确保完全清除状态
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
activeMutation.reset();
form.reset(DEFAULT_VALUES); // ← 推荐用法
}
onOpenChange(isOpen);
};Common Patterns
常见模式
Estado derivado con watch()
基于watch()的派生状态
Elimina duplicado. Usar para derivar valores del form state.
useStatewatch()typescript
// ❌ 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)} />Beneficio: Una sola fuente de verdad (form state).
消除重复的useState。使用从表单状态派生值。
watch()typescript
// ❌ 旧写法 - 状态重复
const [selectedType, setSelectedType] = useState<boolean>(false);
<Button onClick={() => {
field.onChange(true);
setSelectedType(true); // ← 手动同步
}} />
// ✅ 新写法 - 从表单状态派生
const selectedType = form.watch("isIncome");
<Button onClick={() => field.onChange(true)} />优势: 单一数据源(表单状态)。
Edit form (values reactivo)
编辑表单(响应式values)
typescript
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,
});
}typescript
function EditEntityForm({ entityId }: { entityId: number }) {
const { data: entity } = useEntity(entityId);
const form = useForm<EntityFormValues>({
resolver: zodResolver(entitySchema),
// 使用values(而非defaultValues)- 当entity变化时自动同步
values: entity ? { name: entity.name, amount: entity.amount } : DEFAULT_VALUES,
});
}Dynamic fields (useFieldArray)
动态字段(useFieldArray)
typescript
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>
</>
);
}typescript
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>
</>
);
}Conditional validation
条件校验
typescript
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
},
);typescript
const schema = z
.object({
type: z.enum(["savings", "credit"]),
creditLimit: z.number("Must be a number").optional(),
})
.refine(
(data) => {
// 如果类型为credit,creditLimit为必填项
if (data.type === "credit") {
return data.creditLimit !== undefined && data.creditLimit > 0;
}
return true;
},
{
message: "Credit limit required for credit accounts",
path: ["creditLimit"], // 错误展示在该字段
},
);Dependent fields
依赖字段
typescript
function ConditionalForm() {
const accountType = form.watch("type");
return (
<>
<FormField name="type" /* ... */ />
{/* Mostrar solo si type = credit */}
{accountType === "credit" && (
<FormField name="creditLimit" /* ... */ />
)}
</>
);
}typescript
function ConditionalForm() {
const accountType = form.watch("type");
return (
<>
<FormField name="type" /* ... */ />
{/* 仅当类型为credit时展示 */}
{accountType === "credit" && (
<FormField name="creditLimit" /* ... */ />
)}
</>
);
}Anti-Patterns (Eliminados en 2026 Refactor)
反模式(2026重构中已移除)
❌ useState para API errors
❌ 使用useState存储API错误
typescript
// ❌ 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 });typescript
// ❌ 错误写法 - TanStack Query已处理状态
const [apiError, setApiError] = useState<ApiError | null>(null);
const onSubmit = (data) => {
setApiError(null);
createMutation.mutate(data, { onError: (error) => setApiError(error) });
};
// ✅ 正确写法 - 使用activeMutation.error
const activeMutation = mode === "create" ? createMutation : updateMutation;
const apiError = activeMutation.error as ApiError | null;
createMutation.mutate(data, { onError: handleFormError });❌ useEffect para sincronizar edit data
❌ 使用useEffect同步编辑数据
typescript
// ❌ 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,
});typescript
// ❌ 错误写法 - 手动同步
useEffect(() => {
if (mode === "edit" && entity) form.reset({ name: entity.name });
}, [mode, entity, form]);
// ✅ 正确写法 - 自动同步
const form = useForm({
values: mode === "edit" && entity ? { name: entity.name } : DEFAULT_VALUES,
});❌ useEffect para limpiar errores
❌ 使用useEffect清除错误
typescript
// ❌ 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);
};typescript
// ❌ 错误写法 - 手动清除
useEffect(() => {
if (open) setApiError(null);
}, [open]);
// ✅ 正确写法 - 在handleOpenChange中集中清除
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
activeMutation.reset(); // 自动清除错误
form.reset(DEFAULT_VALUES);
}
onOpenChange(isOpen);
};❌ getFieldErrors() helper repetido
❌ 重复编写getFieldErrors()工具函数
typescript
// ❌ 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 />typescript
// ❌ 错误写法 - 每个表单手动编写工具函数
const getFieldErrors = (name: string) =>
apiError?.errors?.filter((e) => e.propertyName === name).map((e) => e.message) || [];
// ✅ 正确写法 - 使用useFormErrorHandler自动处理
const handleFormError = useFormErrorHandler(form);
createMutation.mutate(data, { onError: handleFormError });
// 通过<FormMessage />自动展示错误❌ Verificar ambas mutations separadamente
❌ 单独校验两个mutations
typescript
// ❌ 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();typescript
// ❌ 错误写法 - 重复的条件逻辑
const isLoading = createMutation.isPending || updateMutation.isPending;
if (!open) {
createMutation.reset();
updateMutation.reset();
}
// ✅ 正确写法 - 使用activeMutation模式
const activeMutation = mode === "create" ? createMutation : updateMutation;
const isSubmitting = activeMutation.isPending;
if (!open) activeMutation.reset();❌ useState derivado (duplicación)
❌ 重复使用useState派生状态
typescript
// ❌ 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)} />typescript
// ❌ 错误写法 - 状态重复+手动同步
const [selectedType, setSelectedType] = useState(false);
<Button onClick={() => { field.onChange(true); setSelectedType(true); }} />
// ✅ 正确写法 - 从表单状态派生
const selectedType = form.watch("isIncome");
<Button onClick={() => field.onChange(true)} />❌ Usar z.coerce.number() en SmartPocket
❌ 在SmartPocket中使用z.coerce.number()
typescript
// ❌ 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))} />typescript
// ❌ 错误写法 - 不符合项目模式
amount: z.coerce.number().min(0);
// ✅ 正确写法 - z.number() + 手动转换
amount: z.number("Must be a number").min(0);
<Input type="number" {...field}
onChange={(e) => field.onChange(parseFloat(e.target.value))} />❌ Validación solo client-side
❌ 仅依赖客户端校验
typescript
// ❌ 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 });
};typescript
// ❌ 错误写法 - 仅依赖Zod(客户端可绕过)
const onSubmit = (data) => {
createMutation.mutate(data);
};
// ✅ 正确写法 - 后端始终校验(唯一可信源)
const onSubmit = (data) => {
createMutation.mutate(data, { onError: handleFormError });
};❌ No limpiar form después de submit
❌ 提交后不清除表单
typescript
// ❌ MAL - valores persisten
const onSubmit = (data) => {
createMutation.mutate(data);
};
// ✅ BIEN - reset en onSuccess
createMutation.mutate(data, {
onSuccess: () => handleOpenChange(false), // Incluye form.reset()
});typescript
// ❌ 错误写法 - 值会保留
const onSubmit = (data) => {
createMutation.mutate(data);
};
// ✅ 正确写法 - 在onSuccess中重置
createMutation.mutate(data, {
onSuccess: () => handleOpenChange(false), // 包含form.reset()
});❌ No definir defaultValues
❌ 未定义defaultValues
typescript
// ❌ MAL - uncontrolled inputs warning
const form = useForm<FormValues>({
/* Falta defaultValues */
});
// ✅ BIEN - siempre definir
const form = useForm<FormValues>({ defaultValues: { name: "", amount: 0 } });typescript
// ❌ 错误写法 - 会出现非受控输入警告
const form = useForm<FormValues>({
/* 缺少defaultValues */
});
// ✅ 正确写法 - 始终定义
const form = useForm<FormValues>({ defaultValues: { name: "", amount: 0 } });❌ No deshabilitar submit durante mutation
❌ mutation期间不禁用提交按钮
typescript
// ❌ 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>typescript
// ❌ 错误写法 - 允许重复提交
<Button type="submit">Create</Button>
// ✅ 正确写法 - pending期间禁用
<Button type="submit" disabled={activeMutation.isPending}>
{activeMutation.isPending ? "Saving..." : "Create"}
</Button>Troubleshooting
问题排查
| 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 |
| 问题 | 原因 | 解决方案 |
|---|---|---|
| "A component is changing an uncontrolled input" | 缺少 | 在 |
| 校验未执行 | 缺少 | 添加 |
| 编辑表单未预填充 | 使用 | 改为 |
| 数字以字符串形式保存 | onChange中缺少转换 | 添加 |
| 提交后表单未清除 | 缺少 | 在 |
| API错误未展示 | 未使用 | 添加 |
| 编辑场景中useEffect无限执行 | defaultValues的依赖不正确 | 改为 |
| 错误重复展示(提示框+行内) | 三层系统配置错误 | 检查main.tsx是否过滤 |
| 派生状态不同步 | 使用重复的useState | 改为 |
| 关闭弹窗时reset不生效 | 未向 | 使用 |