Marcos N
← Voltar ao blog

React e acessibilidade

Como criar um formulário acessível em React com validação

Crie um formulário em React e TypeScript com labels, erros por campo, foco no primeiro erro e feedback de envio, sem bibliotecas de formulário.

Marcos N6 min de leitura
Como criar um formulário acessível em React com validação

Um formulário acessível precisa identificar os campos, explicar os erros e comunicar o resultado do envio. Em React, isso envolve HTML semântico, estado para as mensagens e gerenciamento de foco quando a validação falha.

Neste tutorial, vamos construir um formulário de contato com nome e e-mail. A implementação usa React e TypeScript, sem uma biblioteca de formulários. O envio será simulado para que o exemplo funcione sem um serviço externo.

O objetivo é demonstrar a interação completa. Em produção, você deve conectar o envio a um endpoint e validar os dados novamente no servidor.

O que acontece quando a pessoa envia?

O fluxo tem quatro momentos: ler os campos, validar, enviar e apresentar o resultado. Um erro de preenchimento deve aparecer junto ao campo correspondente. Uma falha de rede pertence ao formulário e não deve apagar o que já foi digitado.

Na interface abaixo, o primeiro campo inválido recebe foco. Cada mensagem fica associada ao respectivo input por aria-describedby, e o input recebe aria-invalid quando existe um erro. Essas decisões seguem a orientação de notificações em formulários da W3C.

Componente completo

Crie ContactForm.tsx e renderize o componente na sua página. A diretiva inicial também permite utilizá-lo como componente cliente em um projeto com React Server Components.

ContactForm.tsx
'use client';

import { useId, useRef, useState, type FormEvent } from 'react';

type Values = { name: string; email: string };
type Errors = Partial<Record<keyof Values, string>>;

function validate(values: Values): Errors {
  const errors: Errors = {};
  if (values.name.length < 2) {
    errors.name = 'Informe seu nome com pelo menos dois caracteres.';
  }
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
    errors.email = 'Informe um e-mail no formato nome@dominio.com.';
  }
  return errors;
}

async function sendContact(values: Values): Promise<void> {
  // Demonstração: substitua pela chamada ao seu endpoint.
  await new Promise<void>(resolve => setTimeout(resolve, 600));
  void values;
}

export default function ContactForm() {
  const id = useId();
  const nameRef = useRef<HTMLInputElement>(null);
  const emailRef = useRef<HTMLInputElement>(null);
  const submitting = useRef(false);
  const [errors, setErrors] = useState<Errors>({});
  const [pending, setPending] = useState(false);
  const [message, setMessage] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (submitting.current) return;

    const form = event.currentTarget;
    const data = new FormData(form);
    const values = {
      name: String(data.get('name') ?? '').trim(),
      email: String(data.get('email') ?? '').trim(),
    };
    const nextErrors = validate(values);
    setErrors(nextErrors);
    setMessage('');

    if (Object.keys(nextErrors).length > 0) {
      setMessage('Revise os campos indicados antes de enviar.');
      requestAnimationFrame(() => {
        (nextErrors.name ? nameRef : emailRef).current?.focus();
      });
      return;
    }

    submitting.current = true;
    setPending(true);
    try {
      await sendContact(values);
      form.reset();
      setMessage('Demonstração concluída. Nenhuma mensagem foi enviada.');
    } catch {
      setMessage('Não foi possível enviar. Seus dados foram mantidos; tente novamente.');
    } finally {
      submitting.current = false;
      setPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate className="contact-form">
      <fieldset disabled={pending}>
        <legend>Solicitar contato</legend>
        <p>Todos os campos são obrigatórios.</p>

        <label htmlFor={`${id}-name`}>Nome</label>
        <input ref={nameRef} id={`${id}-name`} name="name"
          autoComplete="name" required
          aria-invalid={Boolean(errors.name)}
          aria-describedby={errors.name ? `${id}-name-error` : undefined} />
        {errors.name && <p id={`${id}-name-error`}>{errors.name}</p>}

        <label htmlFor={`${id}-email`}>E-mail</label>
        <input ref={emailRef} id={`${id}-email`} name="email"
          type="email" autoComplete="email" required
          aria-invalid={Boolean(errors.email)}
          aria-describedby={errors.email ? `${id}-email-error` : undefined} />
        {errors.email && <p id={`${id}-email-error`}>{errors.email}</p>}

        <button type="submit">{pending ? 'Enviando…' : 'Enviar contato'}</button>
      </fieldset>
      <p role="status">{message}</p>
    </form>
  );
}

O exemplo usa campos não controlados: o navegador mantém seus valores e o React gerencia os estados da interação. Isso é suficiente quando você precisa ler os dados no envio. Campos controlados podem ser úteis quando outras partes da interface dependem de cada alteração.

O useId evita repetir identificadores se houver duas instâncias do formulário na página. A referência submitting bloqueia uma segunda execução durante o envio; o estado pending atualiza a interface. O fieldset desabilitado impede alterações enquanto a demonstração está em andamento.

Estilos que ajudam a reconhecer os estados

Inclua estas regras no CSS do projeto. A mensagem textual acompanha a borda de erro: a cor não é o único sinal de que algo precisa ser corrigido.

contact-form.css
.contact-form { max-width: 32rem; }
.contact-form fieldset { display: grid; gap: .75rem; border: 0; padding: 0; }
.contact-form legend { font-size: 1.5rem; font-weight: 600; }
.contact-form input, .contact-form button {
  padding: .8rem 1rem;
  border: 1px solid #b7b1c1;
  border-radius: .5rem;
  font: inherit;
}
.contact-form input { background: white; color: #171717; }
.contact-form [aria-invalid="true"] { border-color: #b42318; }
.contact-form input + p { color: #b42318; margin: 0; }
.contact-form button { background: #7545ff; color: white; cursor: pointer; }
.contact-form :focus-visible { outline: 3px solid #5428b8; outline-offset: 3px; }
.contact-form :disabled { cursor: wait; opacity: .7; }

Por que usar noValidate?

O atributo desliga as mensagens automáticas de validação do navegador. Neste exemplo, assumimos essa responsabilidade para apresentar mensagens consistentes e direcionar o foco.

Ele não é obrigatório em todo formulário. A validação nativa pode atender a fluxos simples e exige menos código. Se você decidir substituí-la, precisa implementar os estados de erro, a comunicação e a recuperação; apenas desativá-la deixa o usuário sem orientação.

A expressão regular do exemplo é uma verificação básica de formato, não uma prova de que a caixa de e-mail existe. Evite regras excessivamente restritivas e mantenha a validação de negócio no servidor.

Como transformar a demonstração em envio real

Substitua sendContact por uma chamada ao serviço escolhido. Verifique o status HTTP e trate falhas antes de mostrar sucesso. Só altere a mensagem de demonstração para uma confirmação real depois de integrar esse serviço.

O backend precisa validar os campos, limitar tamanhos e tratar abuso conforme o contexto. Não coloque credenciais de serviços de e-mail no componente cliente. Um botão desabilitado melhora a interação, mas não impede chamadas diretas à API.

Checklist de teste

  • Envie vazio e confirme o foco no campo de nome.
  • Preencha o nome e deixe um e-mail inválido: o foco deve ir ao e-mail.
  • Use somente Tab, Shift+Tab e Enter para completar o fluxo.
  • Confira as mensagens com um leitor de tela.
  • Simule uma rejeição em sendContact: os valores devem permanecer.
  • Renderize duas instâncias e verifique se os IDs são diferentes.

Perguntas frequentes

Preciso de uma biblioteca para criar um formulário acessível?

Não. HTML e React oferecem os recursos necessários para este fluxo. Uma biblioteca pode facilitar formulários maiores, mas não decide por você os rótulos, as mensagens, o foco e o comportamento dos estados.

Validação no front-end substitui a do servidor?

Não. Ela ajuda quem está preenchendo. O servidor continua responsável por aceitar ou rejeitar os dados recebidos.

Devo validar a cada tecla?

Depende do campo e do contexto. Mostrar um erro antes de a pessoa terminar de digitar pode interromper a tarefa. Aqui, a validação ocorre no envio; uma evolução possível é revalidar campos já marcados com erro ao sair deles.

Um formulário faz parte de uma experiência maior. Veja também como organizar um site até o contato e meus projetos de UX/UI e front-end.