O que Todo Dev Deve Saber sobre Hooks para Formulários: Abstraindo Validação e Estado de Campos Já leu

Entendendo o Problema: Estado e Validação em Formulários Quando começamos a trabalhar com formulários em aplicações React, logo percebemos que gerenciar estado e validação se torna uma tarefa repetitiva e propensa a erros. Cada campo de um formulário precisa rastrear seu valor, validar conforme regras de negócio, exibir mensagens de erro e manter um estado de "foi tocado" para não mostrar erros prematuramente. Multiplicar isso por dez campos diferentes em vários formulários resulta em código duplicado e difícil de manter. A solução tradicional — usando diretamente para cada campo — funciona, mas gera verbosidade desnecessária. É aqui que entram os Hooks customizados: abstrações reutilizáveis que encapsulam a lógica complexa de um campo de formulário em uma interface simples e testável. Um Hook customizado bem construído não apenas reduz linhas de código, mas também padroniza comportamentos e torna a lógica de validação independente da renderização visual. Construindo um Hook Customizado para Campos de Formulário A Estrutura Básica Um Hook para campo

Entendendo o Problema: Estado e Validação em Formulários

Quando começamos a trabalhar com formulários em aplicações React, logo percebemos que gerenciar estado e validação se torna uma tarefa repetitiva e propensa a erros. Cada campo de um formulário precisa rastrear seu valor, validar conforme regras de negócio, exibir mensagens de erro e manter um estado de "foi tocado" para não mostrar erros prematuramente. Multiplicar isso por dez campos diferentes em vários formulários resulta em código duplicado e difícil de manter.

A solução tradicional — usando useState diretamente para cada campo — funciona, mas gera verbosidade desnecessária. É aqui que entram os Hooks customizados: abstrações reutilizáveis que encapsulam a lógica complexa de um campo de formulário em uma interface simples e testável. Um Hook customizado bem construído não apenas reduz linhas de código, mas também padroniza comportamentos e torna a lógica de validação independente da renderização visual.

Construindo um Hook Customizado para Campos de Formulário

A Estrutura Básica

Um Hook para campo de formulário deve gerenciar pelo menos quatro responsabilidades: o valor atual do campo, seu estado de validação, se foi tocado pelo usuário e métodos para alterar esses estados. Vamos começar com uma implementação simples e incremental.

import { useState, useCallback } from 'react';

export const useField = (initialValue = '', validator = null) => {
  const [value, setValue] = useState(initialValue);
  const [touched, setTouched] = useState(false);
  const [error, setError] = useState('');

  const validate = useCallback(() => {
    if (!validator) {
      setError('');
      return true;
    }

    const validationResult = validator(value);
    const isValid = validationResult === true;
    setError(isValid ? '' : validationResult);
    return isValid;
  }, [value, validator]);

  const handleChange = useCallback((e) => {
    const newValue = e.target.value;
    setValue(newValue);
  }, []);

  const handleBlur = useCallback(() => {
    setTouched(true);
    validate();
  }, [validate]);

  const reset = useCallback(() => {
    setValue(initialValue);
    setTouched(false);
    setError('');
  }, [initialValue]);

  return {
    value,
    setValue,
    error,
    touched,
    handleChange,
    handleBlur,
    validate,
    reset,
    bind: {
      value,
      onChange: handleChange,
      onBlur: handleBlur,
    },
  };
};

Este Hook retorna tanto métodos individuais quanto um objeto bind que pode ser distribuído diretamente com spread em um input. O parâmetro validator é uma função que recebe o valor e retorna true se válido ou uma string com a mensagem de erro. Dessa forma, a lógica de validação é completamente desacoplada do Hook.

Usando o Hook em um Componente

A beleza dessa abstração é vista imediatamente ao usar em um componente. Veja como fica simples:

import React from 'react';
import { useField } from './useField';

const emailValidator = (value) => {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(value) ? true : 'Email inválido';
};

const passwordValidator = (value) => {
  if (value.length < 8) return 'Senha deve ter no mínimo 8 caracteres';
  if (!/[A-Z]/.test(value)) return 'Senha deve conter letra maiúscula';
  if (!/[0-9]/.test(value)) return 'Senha deve conter número';
  return true;
};

export const LoginForm = () => {
  const email = useField('', emailValidator);
  const password = useField('', passwordValidator);

  const handleSubmit = (e) => {
    e.preventDefault();

    const emailValid = email.validate();
    const passwordValid = password.validate();

    if (emailValid && passwordValid) {
      console.log('Formulário válido:', {
        email: email.value,
        password: password.value,
      });
      // Enviar para API
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          {...email.bind}
          placeholder="seu@email.com"
        />
        {email.touched && email.error && (
          <span className="error">{email.error}</span>
        )}
      </div>

      <div>
        <label htmlFor="password">Senha:</label>
        <input
          id="password"
          type="password"
          {...password.bind}
          placeholder="Sua senha"
        />
        {password.touched && password.error && (
          <span className="error">{password.error}</span>
        )}
      </div>

      <button type="submit">Entrar</button>
    </form>
  );
};

Perceba que em apenas três linhas por campo (inicialização do Hook, renderização do input com spread e exibição condicional de erro), temos toda a funcionalidade. O mesmo Hook pode ser reutilizado em qualquer outro formulário com validadores diferentes.

Evoluindo: Um Hook para Formulários Completos

Gerenciando Múltiplos Campos

Para formulários mais complexos, é interessante ter um Hook que gerencie múltiplos campos simultaneamente. Isso permite validar o formulário inteiro, verificar se algum campo foi alterado e resetar todos os campos de uma vez.

import { useCallback, useState } from 'react';

export const useForm = (initialValues, onSubmit, validators = {}) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const validateField = useCallback((name, value) => {
    const validator = validators[name];
    if (!validator) return '';

    const result = validator(value);
    return result === true ? '' : result;
  }, [validators]);

  const validateAllFields = useCallback(() => {
    const newErrors = {};
    let isValid = true;

    Object.keys(initialValues).forEach((fieldName) => {
      const error = validateField(fieldName, values[fieldName]);
      if (error) {
        newErrors[fieldName] = error;
        isValid = false;
      }
    });

    setErrors(newErrors);
    return isValid;
  }, [initialValues, values, validateField]);

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const fieldValue = type === 'checkbox' ? checked : value;

    setValues((prev) => ({
      ...prev,
      [name]: fieldValue,
    }));

    // Validação em tempo real apenas se o campo foi tocado
    if (touched[name]) {
      const error = validateField(name, fieldValue);
      setErrors((prev) => ({
        ...prev,
        [name]: error,
      }));
    }
  }, [touched, validateField]);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched((prev) => ({
      ...prev,
      [name]: true,
    }));

    const error = validateField(name, values[name]);
    setErrors((prev) => ({
      ...prev,
      [name]: error,
    }));
  }, [values, validateField]);

  const handleSubmit = useCallback(
    async (e) => {
      e.preventDefault();
      setTouched(
        Object.keys(initialValues).reduce((acc, field) => {
          acc[field] = true;
          return acc;
        }, {})
      );

      if (validateAllFields()) {
        setIsSubmitting(true);
        try {
          await onSubmit(values);
        } finally {
          setIsSubmitting(false);
        }
      }
    },
    [initialValues, validateAllFields, values, onSubmit]
  );

  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm,
    setFieldValue: (name, value) => {
      setValues((prev) => ({ ...prev, [name]: value }));
    },
  };
};

Aplicando em um Formulário Realista

Agora temos um Hook poderoso que gerencia o estado completo do formulário. Veja como fica um formulário de cadastro:

import React from 'react';
import { useForm } from './useForm';

const signupValidators = {
  name: (value) => {
    if (!value.trim()) return 'Nome é obrigatório';
    if (value.length < 3) return 'Nome deve ter pelo menos 3 caracteres';
    return true;
  },
  email: (value) => {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return regex.test(value) ? true : 'Email inválido';
  },
  phone: (value) => {
    const regex = /^\(\d{2}\)\s\d{4,5}-\d{4}$/;
    return regex.test(value) ? true : 'Telefone deve estar no formato (XX) XXXXX-XXXX';
  },
  password: (value) => {
    if (value.length < 8) return 'Senha deve ter no mínimo 8 caracteres';
    if (!/[A-Z]/.test(value)) return 'Senha deve conter letra maiúscula';
    if (!/[0-9]/.test(value)) return 'Senha deve conter número';
    if (!/[!@#$%^&*]/.test(value)) return 'Senha deve conter caractere especial';
    return true;
  },
  terms: (value) => {
    return value === true ? true : 'Você deve aceitar os termos de serviço';
  },
};

export const SignupForm = () => {
  const form = useForm(
    {
      name: '',
      email: '',
      phone: '',
      password: '',
      terms: false,
    },
    async (values) => {
      // Simular chamada à API
      console.log('Enviando dados:', values);
      // const response = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
    },
    signupValidators
  );

  return (
    <form onSubmit={form.handleSubmit}>
      <div className="field-group">
        <label htmlFor="name">Nome Completo:</label>
        <input
          id="name"
          name="name"
          type="text"
          value={form.values.name}
          onChange={form.handleChange}
          onBlur={form.handleBlur}
          aria-invalid={form.touched.name && !!form.errors.name}
        />
        {form.touched.name && form.errors.name && (
          <span className="error">{form.errors.name}</span>
        )}
      </div>

      <div className="field-group">
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          name="email"
          type="email"
          value={form.values.email}
          onChange={form.handleChange}
          onBlur={form.handleBlur}
          aria-invalid={form.touched.email && !!form.errors.email}
        />
        {form.touched.email && form.errors.email && (
          <span className="error">{form.errors.email}</span>
        )}
      </div>

      <div className="field-group">
        <label htmlFor="phone">Telefone:</label>
        <input
          id="phone"
          name="phone"
          type="tel"
          value={form.values.phone}
          onChange={form.handleChange}
          onBlur={form.handleBlur}
          aria-invalid={form.touched.phone && !!form.errors.phone}
        />
        {form.touched.phone && form.errors.phone && (
          <span className="error">{form.errors.phone}</span>
        )}
      </div>

      <div className="field-group">
        <label htmlFor="password">Senha:</label>
        <input
          id="password"
          name="password"
          type="password"
          value={form.values.password}
          onChange={form.handleChange}
          onBlur={form.handleBlur}
          aria-invalid={form.touched.password && !!form.errors.password}
        />
        {form.touched.password && form.errors.password && (
          <span className="error">{form.errors.password}</span>
        )}
      </div>

      <div className="field-group checkbox">
        <label htmlFor="terms">
          <input
            id="terms"
            name="terms"
            type="checkbox"
            checked={form.values.terms}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
          />
          Aceito os termos de serviço
        </label>
        {form.touched.terms && form.errors.terms && (
          <span className="error">{form.errors.terms}</span>
        )}
      </div>

      <button type="submit" disabled={form.isSubmitting}>
        {form.isSubmitting ? 'Cadastrando...' : 'Criar Conta'}
      </button>
      <button type="reset" onClick={form.resetForm}>
        Limpar
      </button>
    </form>
  );
};

Padrões Avançados e Otimizações

Validação Assíncrona

Em muitos casos, você precisa validar dados no servidor — por exemplo, verificar se um email já existe. O Hook useForm pode ser estendido para suportar validadores assíncronos com debounce:

import { useCallback, useState, useRef } from 'react';

export const useFormWithAsync = (initialValues, onSubmit, validators = {}) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isValidating, setIsValidating] = useState(false);
  const debounceTimers = useRef({});

  const validateField = useCallback(async (name, value) => {
    const validator = validators[name];
    if (!validator) return '';

    setIsValidating(true);
    try {
      const result = await Promise.resolve(validator(value));
      return result === true ? '' : result;
    } finally {
      setIsValidating(false);
    }
  }, [validators]);

  const handleChange = useCallback(
    (e) => {
      const { name, value, type, checked } = e.target;
      const fieldValue = type === 'checkbox' ? checked : value;

      setValues((prev) => ({
        ...prev,
        [name]: fieldValue,
      }));

      // Debounce validação assíncrona
      if (touched[name]) {
        clearTimeout(debounceTimers.current[name]);
        debounceTimers.current[name] = setTimeout(async () => {
          const error = await validateField(name, fieldValue);
          setErrors((prev) => ({
            ...prev,
            [name]: error,
          }));
        }, 500);
      }
    },
    [touched, validateField]
  );

  const handleBlur = useCallback(
    async (e) => {
      const { name } = e.target;
      setTouched((prev) => ({
        ...prev,
        [name]: true,
      }));

      const error = await validateField(name, values[name]);
      setErrors((prev) => ({
        ...prev,
        [name]: error,
      }));
    },
    [values, validateField]
  );

  const handleSubmit = useCallback(
    async (e) => {
      e.preventDefault();

      // Aguardar todas as validações
      const allTouched = Object.keys(initialValues).reduce((acc, field) => {
        acc[field] = true;
        return acc;
      }, {});
      setTouched(allTouched);

      setIsSubmitting(true);
      try {
        const newErrors = {};
        for (const fieldName of Object.keys(initialValues)) {
          const error = await validateField(fieldName, values[fieldName]);
          if (error) newErrors[fieldName] = error;
        }

        if (Object.keys(newErrors).length === 0) {
          await onSubmit(values);
        } else {
          setErrors(newErrors);
        }
      } finally {
        setIsSubmitting(false);
      }
    },
    [initialValues, validateField, values, onSubmit]
  );

  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    Object.values(debounceTimers.current).forEach(clearTimeout);
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValidating,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm,
    setFieldValue: (name, value) => {
      setValues((prev) => ({ ...prev, [name]: value }));
    },
  };
};

Aqui usamos debounceTimers.current para armazenar timeouts de validação e evitar múltiplas chamadas enquanto o usuário está digitando. A validação só ocorre 500ms após a última mudança.

Exemplo de Validador Assíncrono

// Simular uma chamada à API
const checkEmailExists = async (email) => {
  const response = await fetch(`/api/check-email?email=${email}`);
  const { exists } = await response.json();
  return exists ? 'Este email já está registrado' : true;
};

const advancedValidators = {
  email: async (value) => {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!regex.test(value)) return 'Email inválido';
    return await checkEmailExists(value);
  },
};

Conclusão

Ao dominar Hooks customizados para formulários, você ganha três vantagens fundamentais. Primeira: eliminação de código duplicado — a lógica complexa fica encapsulada e reutilizável entre projetos. Segunda: separação de responsabilidades — a validação é independente da renderização, facilitando testes unitários e manutenção. Terceira: escalabilidade — seus Hooks evoluem para suportar casos complexos como validação assíncrona, campos dependentes e integrações com APIs sem quebrar componentes que já os usam.

O investimento inicial em construir Hooks bem estruturados se paga rapidamente quando você precisa manter cinco formulários em vez de um, e todas as suas validações estão centralizadas, testáveis e compreensíveis.

Referências


Artigos relacionados