Inicio / TypeScript / React: Frontend Moderno con TypeScript / Formularios en React

Formularios en React

Componentes controlados y no controlados, validación y React Hook Form con Zod.

Intermedio Funciones Web
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Formularios en React

El manejo de formularios es una de las tareas más frecuentes en aplicaciones web. React ofrece dos enfoques: componentes controlados (la norma) y componentes no controlados (con refs).


Componentes controlados

En un componente controlado, React es la fuente de verdad del valor del input. Cada cambio pasa por el estado:

function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    console.log({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}             // Valor controlado por el estado
        onChange={e => setEmail(e.target.value)}  // Actualizar estado en cada tecla
      />
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      <button type="submit">Entrar</button>
    </form>
  )
}

Ventajas de controlados

  • Validación en tiempo real
  • Control total sobre el valor (formateo, limitar caracteres)
  • Estado siempre sincronizado con la UI
  • Fácil de testear

Formulario con estado unificado

Para formularios con muchos campos, usa un solo objeto de estado:

interface FormData {
  name: string
  email: string
  role: string
  bio: string
  newsletter: boolean
}

function ProfileForm() {
  const [form, setForm] = useState<FormData>({
    name: '',
    email: '',
    role: 'developer',
    bio: '',
    newsletter: false,
  })

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
  ) => {
    const { name, value, type } = e.target
    const checked = (e.target as HTMLInputElement).checked

    setForm(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }))
  }

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    console.log(form)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={form.name} onChange={handleChange} placeholder="Nombre" />

      <input name="email" type="email" value={form.email} onChange={handleChange} placeholder="Email" />

      <select name="role" value={form.role} onChange={handleChange}>
        <option value="developer">Developer</option>
        <option value="designer">Designer</option>
        <option value="manager">Manager</option>
      </select>

      <textarea name="bio" value={form.bio} onChange={handleChange} rows={4} />

      <label>
        <input name="newsletter" type="checkbox" checked={form.newsletter} onChange={handleChange} />
        Suscribirme al newsletter
      </label>

      <button type="submit">Guardar</button>
    </form>
  )
}

Validación manual

interface Errors {
  name?: string
  email?: string
  password?: string
}

function RegisterForm() {
  const [form, setForm] = useState({ name: '', email: '', password: '' })
  const [errors, setErrors] = useState<Errors>({})
  const [submitted, setSubmitted] = useState(false)

  const validate = (data: typeof form): Errors => {
    const errs: Errors = {}
    if (!data.name.trim()) errs.name = 'El nombre es requerido'
    if (!data.email.includes('@')) errs.email = 'Email inválido'
    if (data.password.length < 8) errs.password = 'Mínimo 8 caracteres'
    return errs
  }

  // Validación en tiempo real (después del primer submit)
  useEffect(() => {
    if (submitted) {
      setErrors(validate(form))
    }
  }, [form, submitted])

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    setSubmitted(true)
    const errs = validate(form)
    setErrors(errs)
    if (Object.keys(errs).length === 0) {
      console.log('Form válido:', form)
    }
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <input name="name" value={form.name} onChange={handleChange} />
        {errors.name && <span className="error">{errors.name}</span>}
      </div>

      <div>
        <input name="email" type="email" value={form.email} onChange={handleChange} />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div>
        <input name="password" type="password" value={form.password} onChange={handleChange} />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>

      <button type="submit">Registrarse</button>
    </form>
  )
}

useReducer para formularios complejos

Para formularios con lógica de validación compleja, useReducer brilla:

interface FormState {
  values: { name: string; email: string; password: string }
  errors: Partial<Record<string, string>>
  touched: Partial<Record<string, boolean>>
  isSubmitting: boolean
}

type FormAction =
  | { type: 'SET_FIELD'; field: string; value: string }
  | { type: 'SET_TOUCHED'; field: string }
  | { type: 'SET_ERRORS'; errors: FormState['errors'] }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_END' }
  | { type: 'RESET' }

const initialState: FormState = {
  values: { name: '', email: '', password: '' },
  errors: {},
  touched: {},
  isSubmitting: false,
}

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, values: { ...state.values, [action.field]: action.value } }
    case 'SET_TOUCHED':
      return { ...state, touched: { ...state.touched, [action.field]: true } }
    case 'SET_ERRORS':
      return { ...state, errors: action.errors }
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true }
    case 'SUBMIT_END':
      return { ...state, isSubmitting: false }
    case 'RESET':
      return initialState
    default:
      return state
  }
}

Componentes no controlados (useRef)

En un componente no controlado, el DOM mantiene el estado y accedes al valor con ref:

function UncontrolledForm() {
  const nameRef = useRef<HTMLInputElement>(null)
  const emailRef = useRef<HTMLInputElement>(null)

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    console.log({
      name: nameRef.current?.value,
      email: emailRef.current?.value,
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="" />
      <input ref={emailRef} type="email" defaultValue="" />
      <button type="submit">Enviar</button>
    </form>
  )
}

¿Cuándo usar no controlados? Integración con código no-React, formularios simples donde no necesitas validación en tiempo real, o cuando la performance es crítica (muchos campos).


React Hook Form (librería popular)

Para formularios complejos en producción, React Hook Form es la opción más popular:

import { useForm } from 'react-hook-form'

interface FormData {
  name: string
  email: string
  password: string
}

function HookFormExample() {
  const {
    register,      // Conecta un input al formulario
    handleSubmit,   // Envuelve tu onSubmit
    formState: { errors, isSubmitting },
  } = useForm<FormData>()

  const onSubmit = async (data: FormData) => {
    await submitToAPI(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name', { required: 'Nombre es requerido' })} />
      {errors.name && <span>{errors.name.message}</span>}

      <input
        type="email"
        {...register('email', {
          required: 'Email es requerido',
          pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Email inválido' }
        })}
      />
      {errors.email && <span>{errors.email.message}</span>}

      <input
        type="password"
        {...register('password', { required: true, minLength: { value: 8, message: 'Mínimo 8 caracteres' } })}
      />
      {errors.password && <span>{errors.password.message}</span>}

      <button disabled={isSubmitting}>
        {isSubmitting ? 'Enviando...' : 'Registrarse'}
      </button>
    </form>
  )
}

Validación con Zod + React Hook Form

import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(2, 'Mínimo 2 caracteres'),
  email: z.string().email('Email inválido'),
  password: z.string().min(8, 'Mínimo 8 caracteres'),
})

type FormData = z.infer<typeof schema>

function ZodForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}
      {/* ... */}
    </form>
  )
}

Resumen

Enfoque Fuente de verdad Uso
Controlado Estado React (useState) Validación en tiempo real, control total
No controlado DOM (useRef) Formularios simples, integración con no-React
React Hook Form Librería (refs + estado) Producción, performance, validación con Zod
useReducer Reducer + dispatch Lógica de validación compleja
🔒

Ejercicio práctico disponible

Validador de formularios con TypeScript

Desbloquear ejercicios
// Validador de formularios con TypeScript
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes