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.
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.
'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 { 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.
