FNSP Web Page

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

Comentários

Ainda ninguém comentou este artigo.

Voltar ao blog

Gostávamos de saber quantas pessoas visitam o site, com o Google Analytics. Sem a sua autorização não corre nada. Política de Privacidade.