Formulários e Server Actions: o caminho todo, do input à base de dados
Um formulário em React era, durante anos, um useState por campo, um onChange por campo, e um onSubmit que fazia preventDefault e um fetch. Muito código para o que o HTML já sabia fazer sozinho desde 1995.
O formulário de comentários deste site é o exemplo que vou usar até ao fim — e não tem um único useState.
1. O formulário já sabe os seus valores
// Antes: o React guarda o que o browser já estava a guardar
const [nome, setNome] = useState('');
<input value={nome} onChange={(e) => setNome(e.target.value)} />
// Agora: o campo tem nome, e o resto sai do FormData
<input name="nome" />Passar uma função ao action faz o React recolher os campos num FormData e entregá-lo. Só se controla um campo quando alguma coisa no ecrã tem mesmo de reagir a cada tecla — um contador de caracteres, uma pesquisa a filtrar enquanto se escreve. Para guardar e enviar, não é preciso.
2. useActionState: o estado da submissão, sem o gerir
const [estado, acao, aEnviar] = useActionState(enviarComentario, null);
<form action={acao}>
<textarea name="texto" required />
<input name="nome" required />
<button disabled={aEnviar}>{aEnviar ? 'A enviar…' : 'Enviar'}</button>
{estado && <p>{estado.mensagem}</p>}
</form>A função recebe (estadoAnterior, dados) e devolve o estado novo. O que vem de graça: o aEnviar durante a viagem, o resultado no fim, e a garantia de que dois cliques seguidos não geram dois envios a competir um com o outro.
Uma armadilha que vale a pena conhecer antes de tropeçar nela: quando se passa uma função ao action, o React limpa o formulário assim que ela devolve — inclusive quando devolve um erro. Está contada com mais detalhe no artigo sobre a migração para o React 19.
3. O prémio: funciona sem JavaScript
Esta é a parte que se perde de vista. Se a função do action for uma Server Action, o formulário é um formulário HTML a sério. Se o JavaScript ainda não carregou, ou falhou, ou está desligado, o browser submete à maneira antiga e a ação corre na mesma.
Não é caridade para quem desliga o JavaScript. É a janela de segundos entre a página aparecer e o bundle estar pronto — em que, com a abordagem do onSubmit e fetch, carregar no botão não faz rigorosamente nada.
4. Validar é do lado do servidor. Sempre.
O required no HTML é conveniência para quem está a preencher. Não é validação: quem submeter o formulário por fora nunca o vê.
export async function enviarComentario(anterior, dados) {
const validado = esquema.safeParse({
nome: dados.get('nome'),
texto: dados.get('texto'),
});
if (!validado.success) {
return { ok: false, mensagem: validado.error.issues[0].message };
}
// ...
}E há um caso que merece destaque próprio, porque é onde quase toda a gente confia de mais: um campo escondido é o que quem submete quiser que ele seja.
<input type="hidden" name="postId" value={artigoId} />Aquele número chega ao servidor como texto vindo da rua. Neste site a ação confirma na base que o artigo existe antes de gravar — sem essa verificação, um id inventado rebentava na chave estrangeira e o visitante levava com um erro 500 em vez de uma mensagem.
5. Uma armadilha para robôs, que não incomoda ninguém
Um formulário aberto na internet recebe robôs. A defesa mais barata que existe é um campo que ninguém vê:
<div className="absolute left-[-9999px]" aria-hidden="true">
<label htmlFor="website">Website</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>Três pormenores fazem a diferença. Não é type="hidden" — os robôs ignoram esses; tem de parecer um campo a sério, com um nome que dê vontade de preencher. O tabIndex={-1} tira-o da navegação por teclado. E o aria-hidden tira-o dos leitores de ecrã.
Do lado do servidor, quando a armadilha é accionada, a ação devolve a mesma mensagem de sucesso de sempre. Dizer «não passaste» a um robô é ensiná-lo a tentar de outra maneira.
6. Gravar não chega: é preciso invalidar a cache
O passo esquecido com mais frequência, e o mais difícil de diagnosticar — porque não dá erro nenhum. A ação grava, devolve sucesso, e a página continua a mostrar o que mostrava antes.
await prisma.comment.create({ data: { ... } });
updateTag(ETIQUETAS.comentarios); // sem isto, a página fica na mesma
return { ok: true, mensagem: 'Comentário enviado.' };Uma nota que custou algum tempo a descobrir: no Next 16 o revalidateTag passou a exigir um perfil de duração e expira mais tarde. Dentro de uma Server Action, o que se quer é ver já o que se acabou de escrever — e para isso é o updateTag.
7. E limitar, porque um formulário é um convite
// Cinco por hora e por IP
if (!dentroDoLimite(`comentario:${await ipDoPedido()}`, 5, 60 * 60 * 1000)) {
return { ok: false, mensagem: 'Demasiados comentários seguidos.' };
}Sem limite, uma tarde de um robô chega para encher a base — e limpar custa sempre mais do que proteger.
O resumo
Campos com name em vez de estado. useActionState para o pendente e o resultado. Validação no servidor, sempre, e desconfiança total de tudo o que venha num campo escondido. E, no fim da ação, invalidar a cache — porque gravar sem invalidar é o bug que não dá erro.