frontend-forms

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SmartPocket - 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:
  • useFormErrorHandler
    - inyección automática de errores API en RHF
  • activeMutation
    - simplifica manejo de create/edit modes
  • values
    (no defaultValues+useEffect) - sincronización reactiva de edit data
  • ✅ Sistema 3 capas - field-level → inline → toast (no duplicación)
  • handleOpenChange
    unificado - reset centralizado
  • ✅ Estado derivado con
    watch()
    - elimina useState duplicado

概述: 标准化模式,每个表单可减少约40行样板代码。
核心模式:
  • useFormErrorHandler
    - 自动将API错误注入React Hook Form
  • activeMutation
    - 简化创建/编辑模式的处理逻辑
  • values
    (而非defaultValues+useEffect)- 编辑数据的响应式同步
  • ✅ 三层系统 - 字段级 → 行内 → 提示框(无重复展示)
  • ✅ 统一的
    handleOpenChange
    - 集中式重置
  • ✅ 基于
    watch()
    的派生状态 - 消除重复的useState

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
useEffect
manual para sincronizar edit data.
typescript
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:
    propertyName
    (backend PascalCase) → campo (camelCase)
  • 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)
  • useEffect
    para limpiar errores
  • ❌ 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
    propertyName
    <FormMessage />
    bajo el campo
  • Error sin
    propertyName
    <ErrorAlert />
    inline arriba del form
  • Toast automático → Solo si NO hay
    propertyName
    (main.tsx)
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 />
  • 自动提示框 → 仅当错误无
    propertyName
    时触发(在main.tsx中配置)
效果: 用户仅在合适位置看到一次错误提示。

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()?
  1. API incompatible:
    z.coerce.number()
    NO acepta
    required_error
    ni
    invalid_type_error
    props
  2. Consistencia: Todo el proyecto usa este pattern
  3. 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()?
  1. API不兼容:
    z.coerce.number()
    不支持
    required_error
    invalid_type_error
    属性
  2. 一致性: 整个项目统一使用此模式
  3. 显式控制: 转换逻辑在组件中可见,便于调试
针对整数(如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

EscenarioUsarRazón
Form create-only (valores estáticos)
defaultValues
Se establece una vez al montar
Form create/edit con mode toggle
values
Sincroniza automáticamente con props
Form con datos externos dinámicos
values
Reacciona a cambios de entity/data
Form con valores hardcodeados
defaultValues
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,
});
场景使用方式原因
仅创建场景的表单(静态值)
defaultValues
组件挂载时一次性设置
支持创建/编辑切换的表单
values
自动与props同步
包含动态外部数据的表单
values
响应entity/data的变化
包含硬编码值的表单
defaultValues
无需响应式更新
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() - 带/不带参数

UsoEfecto
form.reset()
Resetea a defaultValues/values originales
form.reset(newValues)
Resetea a valores específicos
form.reset(DEFAULT_VALUES)
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);
};

用法效果
form.reset()
重置为原始的defaultValues/values
form.reset(newValues)
重置为指定值
form.reset(DEFAULT_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
useState
duplicado. Usar
watch()
para derivar valores del form state.
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

问题排查

ProblemaCausaSolución
"A component is changing an uncontrolled input"Falta
defaultValues
Definir
defaultValues
en
useForm()
Validación no ejecutaFalta
resolver
Agregar
resolver: zodResolver(schema)
Edit form no pre-populaUsar
defaultValues
en vez de
values
Cambiar a
values
para reactive updates
Números se guardan como stringsFalta conversión en
onChange
Agregar
onChange={(e) => field.onChange(parseFloat(e.target.value))}
Form no limpia después de submitFalta
form.reset()
Llamar
reset()
en
onSuccess
Errores de API no aparecenNo usar
useFormErrorHandler
Agregar
onError: handleFormError
useEffect ejecuta infinitamente en editDependencias incorrectas con
defaultValues
Cambiar a
values
(elimina useEffect)
Errores duplicados (toast + inline)Sistema de 3 capas mal configuradoVerificar main.tsx filtra
propertyName
Estado derivado desincronizadoUsar
useState
duplicado
Cambiar a
form.watch()
Reset no funciona al cerrar modalNo pasar valores a
reset()
Usar
form.reset(DEFAULT_VALUES)

问题原因解决方案
"A component is changing an uncontrolled input"缺少
defaultValues
useForm()
中定义
defaultValues
校验未执行缺少
resolver
添加
resolver: zodResolver(schema)
编辑表单未预填充使用
defaultValues
而非
values
改为
values
实现响应式更新
数字以字符串形式保存onChange中缺少转换添加
onChange={(e) => field.onChange(parseFloat(e.target.value))}
提交后表单未清除缺少
form.reset()
onSuccess
中调用
reset()
API错误未展示未使用
useFormErrorHandler
添加
onError: handleFormError
编辑场景中useEffect无限执行defaultValues的依赖不正确改为
values
(消除useEffect)
错误重复展示(提示框+行内)三层系统配置错误检查main.tsx是否过滤
propertyName
派生状态不同步使用重复的useState改为
form.watch()
关闭弹窗时reset不生效未向
reset()
传递值
使用
form.reset(DEFAULT_VALUES)

References

参考资料