React Hook Form y Zod: Un año en producción sin código boilerplate ni problemas ocultos
Tras un año de uso activo de la combinación de React Hook Form y Zod en producción en un proyecto grande, el equipo confirma: el enfoque declarativo para formularios reduce el volumen de código en un 30–40 %, pero requiere atención a los detalles de implementación. Compartimos los problemas que descubrimos y cómo superarlos.
De MobX a formularios declarativos: ¿Por qué cambiar de enfoque?
En iteraciones anteriores del proyecto, los formularios se construían usando MobX en un estilo MVC. Este método permitía controlar el estado, pero generaba código boilerplate: cada campo requería métodos separados en el controlador, arrays de errores y sincronización manual con la UI. Por ejemplo, agregar un nuevo campo implicaba crear:
export class SomeController {
public setDuration = (v: number) => {
this.store.duration = v;
}
}
Ese código template no estaba relacionado con la lógica de negocio, pero ocupaba hasta el 60 % del volumen del componente. Cambiar a React Hook Form (RHF) con Zod resolvió el problema: el esquema de validación se describe de forma declarativa y la librería automatiza la sincronización del estado y el manejo de errores.
Ejemplo de un formulario básico:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(1, 'Enter name'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(0, 'Invalid vozrast').optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function ExampleForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
},
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<button type="submit">Send</button>
</form>
);
}
Resultado: el volumen de código se redujo, el mantenimiento se simplificó y la velocidad de desarrollo de nuevos formularios aumentó. Sin embargo, durante el uso en el mundo real, surgieron matices no cubiertos en la documentación.
Tres problemas en la combinación de React Hook Form y Zod
Durante un año usando RHF y Zod, nos topamos con tres problemas clave que pueden romper la funcionalidad del formulario en ciertos escenarios.
1. Manejo de undefined y null
RHF no soporta undefined como valor de campo. Intentar establecer:
methods.setValue('age', undefined)
hace que el campo ignore el cambio. La documentación lo menciona, pero no es obvio. La solución estándar es usar null y declarar el campo como .nullable():
const schema = z.object({
age: z.number().nullable()
});
methods.setValue('age', null);
Sin embargo, esto puede entrar en conflicto con componentes personalizados que esperan un número. Por ejemplo, un componente de entrada de edad podría fallar con null. El otro lado: el componente devuelve undefined al borrarse, pero RHF no procesa ese valor.
Los botones de reset añaden complejidad extra. El método reset() toma valores de defaultValues, pero si están en undefined, el reset falla. Solución: usar null o una cadena vacía para campos de cadena.
2. Errores de tipo inesperados en Zod
El problema surge con desajustes de tipos de datos. Por ejemplo, este esquema:
const schema = z.object({
field1: z.string().min(1, 'Fill field').optional()
});
Si el componente devuelve null (no undefined), Zod lanza invalid type (expected string received null). Los usuarios ven un mensaje del sistema en lugar de tu texto personalizado.
Solución: establece explícitamente el texto de error para todos los tipos de validación:
const schema = z.object({
field1: z.string('Fill field').min(1, 'Fill field').optional()
});
Esto asegura que cualquier error muestre tu mensaje.
3. Inestabilidad de la bandera isDirty
La bandera formState.isDirty indica si el formulario tiene cambios. Pero inicializar con defaultValues vacíos:
const methods = useForm({
resolver: zodResolver(formSchema),
defaultValues: {},
});
hace que isDirty se vuelva true justo después del render, incluso si el usuario no ha tocado nada. Mientras tanto, dirtyFields sigue siendo un objeto vacío.
Esto proviene de un desajuste entre la estructura de defaultValues y el esquema. Para evitar disparos falsos, asegúrate de que defaultValues incluya todos los campos del esquema, incluso con undefined o null.
¿Cómo evitar errores comunes?
Basándonos en la experiencia, aquí van prácticas probadas:
- Para manejar valores vacíos:
- Campos de cadena: usa una cadena vacía como predeterminado.
- Campos numéricos/objeto: aplica .nullable() en Zod y null para valores.
- Verifica siempre que los componentes personalizados soporten el tipo elegido.
- Para validación fiable:
- Especifica un mensaje de error general para cada paso de validación, como en el ejemplo anterior.
- Prueba escenarios de null y undefined durante el desarrollo del componente.
- Para un comportamiento correcto de isDirty:
- Inicializa defaultValues para que coincida con el esquema, cubriendo todos los campos.
- Evita el objeto vacío {}: lista explícitamente los campos con undefined o null.
También recomendamos agregar helpers unificados para formularios a tu proyecto, como una función de reset que maneje las peculiaridades de RHF:
const resetForm = () => {
methods.reset({
name: null,
email: null,
age: null,
});
};
Lecciones clave
- La reducción de código no elimina la necesidad de pruebas: El enfoque declarativo ahorra tiempo, pero exige comprobaciones rigurosas de casos límite.
- La seguridad de tipos es tu aliada: Zod con TypeScript previene muchos errores, pero los esquemas deben configurarse correctamente.
- Documenta las peculiaridades: Registra soluciones para problemas comunes de RHF y Zod en la documentación interna de tu proyecto.
— Editorial Team
Aún no hay comentarios.