Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

Formulário controlado no React: value e onChange

Como ligar input, textarea, select e checkbox ao estado, validar antes do submit e por que aparece o aviso de campo sem onChange.

Rodolfo Mori15 min de leitura

Uma responsável digita “Helena Prado” na matrícula, mas o resumo ao lado continua vazio. Ao tentar corrigir pelo código, o campo trava e não aceita mais teclas. O problema é ter duas versões do mesmo nome: uma no campo e outra na aplicação.

React é uma biblioteca JavaScript para construir interfaces. Estado é a memória do componente; DOM é a representação da página mantida pelo navegador. Um formulário controlado escolhe o estado como registro oficial e faz o DOM refletir esse valor.

Um form ou formulário reúne campos e uma ação de submit, o envio. A prop value entrega o valor ao campo; onChange recebe cada edição; o setter é a função que atualiza o estado; e um novo render executa o componente para descrever a tela atualizada. Prop é uma configuração entregue a um elemento.

Todos os exemplos usam a ficha da Escola Aurora, um cenário fictício criado para esta aula: nome, e-mail, turno, nível e aviso por WhatsApp. Nos testes, Node.js executa JavaScript fora do navegador, jsdom simula o DOM e createRoot monta o componente.

O que é um formulário controlado no React?

É um formulário em que o estado guarda o valor, o campo o mostra por value e comunica mudanças por onChange. Assim existe uma fonte única da verdade, isto é, um registro oficial para cada dado.

Imagine a secretaria anotando a matrícula em dois cadernos independentes. Se o turno muda em apenas um deles, ninguém sabe qual registro vale. O DOM consegue guardar o valor do input, e o React também consegue guardá-lo no estado. No formulário controlado, a gente escolhe um caderno oficial: o estado. O DOM apenas reflete o que recebeu por value.

O limite da analogia é que papel não reage sozinho. No React, o setter cria o novo registro e o render atualiza o campo, o resumo e qualquer parte dependente.

Experimente você mesmo

No primeiro exemplo, procure qual elo falta na sequência “evento → setter → novo render → novo value”. Antes de digitar, preveja quantas linhas de render aparecerão e qual texto ficará no campo. Digite uma letra e confira a saída.

Sem value controlado, o <input> guarda o próprio texto no DOM: é um campo não controlado. No controlado, o estado fornece o valor e a função de atualização do useState o muda. O ciclo fecha assim:

estado useState('')
plaintext
<rect x="250" y="70" width="160" height="60" rx="8" stroke="currentColor" fill="none" />
<text x="330" y="96" text-anchor="middle" fill="currentColor" font-size="14">&lt;input&gt;</text>
<text x="330" y="115" text-anchor="middle" fill="currentColor" font-size="12">na tela</text>

<rect x="480" y="70" width="160" height="60" rx="8" stroke="currentColor" fill="none" />
<text x="560" y="96" text-anchor="middle" fill="currentColor" font-size="14">onChange</text>
<text x="560" y="115" text-anchor="middle" fill="currentColor" font-size="12">setEstado(...)</text>

<path d="M180 100 H242" stroke="currentColor" fill="none" />
<polygon points="250,100 240,95 240,105" fill="currentColor" />
<text x="211" y="90" text-anchor="middle" fill="currentColor" font-size="12">value</text>

<path d="M410 100 H472" stroke="currentColor" fill="none" />
<polygon points="480,100 470,95 470,105" fill="currentColor" />
<text x="441" y="90" text-anchor="middle" fill="currentColor" font-size="12">tecla</text>

<path d="M560 130 V175 H100 V138" stroke="currentColor" fill="none" />
<polygon points="100,130 95,140 105,140" fill="currentColor" />
<text x="330" y="193" text-anchor="middle" fill="currentColor" font-size="12">novo estado dispara novo render</text>

Se você entrega o value e esquece o onChange, a seta de volta não existe. O campo fica preso no valor inicial para sempre — e o React fala isso em voz alta:

jsx
import { useState } from 'react';

function FichaMatricula() {
  const [nome, setNome] = useState('Helena');
  console.log('render →', JSON.stringify(nome));

  return (
    <form>
      <label htmlFor="nome">Nome do aluno</label>
      <input id="nome" value={nome} />
    </form>
  );
}
render → "Helena" You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`. value do input depois de digitar: "Helena"

Leia as três linhas em ordem, porque cada uma diz uma coisa diferente. O componente renderizou uma vez. O aviso apareceu no momento em que o campo foi criado, não quando alguém digitou. E depois de digitar Helena Prado no campo, o valor voltou para Helena: o React reescreveu o conteúdo do elemento com o que estava no value.

Repare também no que não aconteceu: não há uma segunda linha de render. O estado nunca mudou, porque não existia ninguém para mudá-lo.

Como value e onChange controlam um input?

value leva o estado ao campo; onChange lê a edição e chama o setter. Cada mudança cria um estado novo, dispara outro render e devolve o valor atualizado ao input.

jsx
import { useState } from 'react';

function FichaMatricula() {
  const [nome, setNome] = useState('');
  console.log('render →', JSON.stringify(nome));

  return (
    <form>
      <label htmlFor="nome">Nome do aluno</label>
      <input id="nome" value={nome} onChange={(evento) => setNome(evento.target.value)} />
      <p>Matrícula de {nome || 'ninguém ainda'}</p>
    </form>
  );
}

Digitando Helena letra por letra, seis teclas, a saída é esta:

render → "" render → "H" render → "He" render → "Hel" render → "Hele" render → "Helen" render → "Helena" parágrafo na tela: Matrícula de Helena

useState('') começa com texto vazio e devolve nome e setNome. O input lê nome; a função de seta — a forma curta com => — envia evento.target.value ao setter; e o parágrafo usa “ninguém ainda” enquanto o nome estiver vazio.

Sete renders para seis teclas: o primeiro é a montagem, os outros seis são as teclas. Isso não é desperdício, é o preço do controle — e é o que faz o parágrafo abaixo do campo acompanhar a digitação sem uma linha de código a mais.

O evento que chega no onChange é um evento sintético, objeto que o React cria para padronizar a interação do navegador. É o assunto de eventos no React. evento.target aponta para o elemento que disparou e entrega o valor novo.

Até aqui, você já sabe: value mostra o estado, onChange entrega a edição e o setter fecha o ciclo que mantém campo e restante da tela sincronizados.

É melhor usar um estado por campo ou um objeto para o formulário?

Para poucos campos independentes, um useState por campo é simples. Quando a ficha cresce, um objeto — conjunto de propriedades nomeadas — reduz funções repetidas e permite usar o atributo name como chave da atualização.

jsx
import { useState } from 'react';

const FICHA_VAZIA = { nome: '', email: '', observacoes: '' };

function FichaMatricula() {
  const [ficha, setFicha] = useState(FICHA_VAZIA);

  function atualizar(evento) {
    const { name, value } = evento.target;
    setFicha((anterior) => ({ ...anterior, [name]: value }));
  }

  console.log('render →', JSON.stringify(ficha));

  return (
    <form>
      <input id="nome" name="nome" value={ficha.nome} onChange={atualizar} />
      <input id="email" name="email" type="email" value={ficha.email} onChange={atualizar} />
      <textarea id="obs" name="observacoes" value={ficha.observacoes} onChange={atualizar} />
    </form>
  );
}

Preenchendo os três campos, um de cada vez:

render → {"nome":"","email":"","observacoes":""} render → {"nome":"Helena Prado","email":"","observacoes":""} render → {"nome":"Helena Prado","email":"responsavel@escolaaurora.com.br","observacoes":""} render → {"nome":"Helena Prado","email":"responsavel@escolaaurora.com.br","observacoes":"Alérgica a amendoim"}

Três detalhes que economizam tempo depois.

O <textarea> do React usa value, e não conteúdo entre as tags como no HTML puro. Isso é uma diferença deliberada da biblioteca: no React ele se comporta como qualquer outro campo.

O [name] entre colchetes é chave computada de objeto — o nome da propriedade sai do valor da variável. É o que permite uma função só para a ficha inteira.

E o setFicha((anterior) => ...) usa a forma de função porque o valor novo depende do anterior. O spread ...anterior copia as propriedades; cada tecla cria um objeto novo em vez de alterar o antigo, que é a regra de atualizar estado sem mutar.

Por que um campo some quando atualizo outro no React?

Porque o setter substitui o objeto inteiro; ele não mistura propriedades automaticamente. Sem ...anterior, a atualização mantém apenas o campo atual e transforma os demais em undefined, sinal de valor ausente.

Na ficha reduzida a dois campos, o spread foi esquecido:

jsx
import { useState } from 'react';

function FichaMatricula() {
  const [ficha, setFicha] = useState({ nome: '', email: '' });

  function atualizar(evento) {
    const { name, value } = evento.target;
    setFicha({ [name]: value }); // faltou o ...anterior
  }

  console.log('render →', JSON.stringify(ficha));

  return (
    <form>
      <input id="nome" name="nome" value={ficha.nome} onChange={atualizar} />
      <input id="email" name="email" value={ficha.email} onChange={atualizar} />
    </form>
  );
}

Experimente você mesmo

Antes de olhar a saída, preveja o objeto depois de preencher nome e depois email. Em seguida, execute o exemplo, preencha os dois campos nessa ordem e compare sua previsão com cada linha de render.

O código não quebra. Ele faz uma coisa pior: apaga silenciosamente os outros campos e o React começa a reclamar de uma coisa aparentemente sem relação.

render → {"nome":"","email":""} render → {"nome":"Helena Prado"} A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components render → {"email":"responsavel@escolaaurora.com.br"} A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components

Acompanhe o raciocínio, porque a mensagem não fala do spread em momento nenhum. Ao digitar o nome, o objeto passou a ser só {nome: ...}. Logo, ficha.email virou undefined, e o campo de e-mail, que era controlado, ficou sem value — daí o primeiro aviso. Ao digitar o e-mail, o objeto virou só {email: ...}, o campo de e-mail voltou a ter valor e o de nome sumiu — daí o segundo, na direção contrária.

Tradução prática: quando React falar em controlado virando não controlado, procure um value que virou undefined. Causas comuns são spread esquecido e campo ausente na resposta do servidor.

Até aqui, você já sabe: ao atualizar estado em objeto, preserve as outras chaves. O aviso sobre controle é o sintoma; o valor ausente é a pista.

Como controlar select, checkbox e radio no React?

Controle o <select> por value; controle checkbox e radio por checked. No evento, texto e seleção chegam por target.value, enquanto a marcação da caixa chega por target.checked.

Por isso, coloque value no próprio <select>, não selected na <option>. Em checkbox e radio, value identifica a opção; checked marca o estado.

jsx
import { useState } from 'react';

function FichaMatricula() {
  const [ficha, setFicha] = useState({ turno: 'manha', nivel: 'iniciante', avisarWhats: false });

  function atualizar(evento) {
    const { name, type, value, checked } = evento.target;
    setFicha((anterior) => ({ ...anterior, [name]: type === 'checkbox' ? checked : value }));
  }

  console.log('render →', JSON.stringify(ficha));

  return (
    <form>
      <select id="turno" name="turno" value={ficha.turno} onChange={atualizar}>
        <option value="manha">Manhã</option>
        <option value="tarde">Tarde</option>
        <option value="noite">Noite</option>
      </select>

      <input id="r1" type="radio" name="nivel" value="iniciante"
        checked={ficha.nivel === 'iniciante'} onChange={atualizar} />
      <input id="r2" type="radio" name="nivel" value="intermediario"
        checked={ficha.nivel === 'intermediario'} onChange={atualizar} />

      <input id="whats" type="checkbox" name="avisarWhats"
        checked={ficha.avisarWhats} onChange={atualizar} />
    </form>
  );
}

Escolhendo o turno da noite, marcando o nível intermediário e ligando o aviso por WhatsApp:

render → {"turno":"manha","nivel":"iniciante","avisarWhats":false} render → {"turno":"noite","nivel":"iniciante","avisarWhats":false} render → {"turno":"noite","nivel":"intermediario","avisarWhats":false} render → {"turno":"noite","nivel":"intermediario","avisarWhats":true} option selecionada: Noite radio iniciante segue marcado? false

Repare no grupo de radio. Os dois botões têm o mesmo name, mas o checked de cada um é uma comparação contra o estado. Não existe um estado por botão: existe um estado nivel, e cada botão pergunta “o valor sou eu?”. Por isso o primeiro desmarcou sozinho quando o segundo foi escolhido.

No início, useState guarda os três controles. atualizar escolhe entre checked e value, e o setter preserva a ficha anterior. No JSX — a marcação devolvida pelo componente — cada prop liga um controle à propriedade correspondente. avisarWhats é um booleano: true ou false.

E por que o type === 'checkbox' no meio do handler? Porque o value de uma caixa de seleção não é o que você imagina. Vale imprimir o que o evento entrega em cada tipo de campo:

jsx
// dentro do mesmo componente, no lugar de atualizar
function inspecionar(evento) {
  const alvo = evento.target;
  console.log(
    `${alvo.name.padEnd(12)} type=${alvo.type.padEnd(8)} value=${JSON.stringify(alvo.value).padEnd(8)} checked=${alvo.checked}`,
  );
  setFicha((a) => ({ ...a, [alvo.name]: alvo.type === 'checkbox' ? alvo.checked : alvo.value }));
}

Trocando o turno e marcando e desmarcando a caixa:

turno type=select-one value="noite" checked=undefined avisarWhats type=checkbox value="on" checked=true avisarWhats type=checkbox value="on" checked=false

O value do checkbox é a string "on" nas duas vezes — marcado e desmarcado. É o valor que o navegador enviaria no formulário tradicional se a caixa estivesse marcada, e não o estado dela. Quem sabe se está marcado é checked. E o <select> não tem checked nenhum: vem undefined.

No segundo exemplo, inspecionar apenas expõe essa diferença. padEnd alinha as colunas do log; depois o mesmo setter escolhe a propriedade correta. A saída confirma que o checkbox muda checked, embora seu value continue sendo "on".

campo prop que você controla onde o valor novo aparece tipo do valor
input de texto, textarea value evento.target.value string
select value no <select> evento.target.value string
checkbox checked evento.target.checked booleano
radio checked={estado === valor} evento.target.value string

Como enviar um formulário React sem recarregar a página?

Por padrão, o navegador envia os campos e navega. Se a aplicação vai tratar o envio em JavaScript e permanecer na tela, o handler — função que trata o evento — chama preventDefault() no onSubmit. Se a navegação pelo HTML for a intenção, mantenha o comportamento nativo.

Esse cancelamento é o mesmo explicado no objeto event com preventDefault. A ficha desta seção junta o que já apareceu: nome, e-mail e turno.

jsx
import { useState } from 'react';

const FICHA_VAZIA = { nome: '', email: '', turno: 'manha' };

function FichaMatricula() {
  const [ficha, setFicha] = useState(FICHA_VAZIA);
  const [enviando, setEnviando] = useState(false);

  function atualizar(evento) {
    const { name, value } = evento.target;
    setFicha((anterior) => ({ ...anterior, [name]: value }));
  }

  function enviar(evento) {
    evento.preventDefault();
    setEnviando(true);
    console.log('defaultPrevented =', evento.defaultPrevented);
    console.log('corpo do POST:', JSON.stringify(ficha));
    console.log('ficha e FICHA_VAZIA são o mesmo objeto?', ficha === FICHA_VAZIA);
  }

  return (
    <form id="ficha" onSubmit={enviar}>
      <input id="nome" name="nome" value={ficha.nome} onChange={atualizar} />
      <input id="email" name="email" value={ficha.email} onChange={atualizar} />
      <select id="turno" name="turno" value={ficha.turno} onChange={atualizar}>
        <option value="manha">Manhã</option>
        <option value="noite">Noite</option>
      </select>
      <button id="botao" type="submit" disabled={enviando}>
        {enviando ? 'Enviando…' : 'Confirmar matrícula'}
      </button>
    </form>
  );
}

Preenchendo a ficha e clicando em “Confirmar matrícula”:

defaultPrevented = true corpo do POST: {"nome":"Helena Prado","email":"responsavel@escolaaurora.com.br","turno":"noite"} ficha e FICHA_VAZIA são o mesmo objeto? false texto do botão depois do envio: Enviando… a página continua em: http://localhost:5173/matricula

Três coisas caem no colo de graça aqui. O ficha já é o objeto que vai no corpo da requisição — não é preciso ler campo por campo do DOM. O FICHA_VAZIA continua intacto, prova de que o estado foi substituído e não alterado. E o disabled={enviando} desativa o botão após o novo render e reduz envios repetidos. A proteção definitiva contra duplicidade ainda pertence ao servidor.

Na leitura do código, atualizar mantém os campos sincronizados e enviar recebe o evento do submit. A primeira linha cancela a navegação; a segunda troca o texto e desativa o botão; os três logs inspecionam o evento, o corpo e a imutabilidade. O onSubmit={enviar} liga a função ao formulário, e o botão de type="submit" dispara essa ação.

Sem o preventDefault, o mesmo clique produz isto:

jsx
// o mesmo formulário, com a primeira linha do enviar removida
function enviar(evento) {
  console.log('handler rodou, defaultPrevented =', evento.defaultPrevented);
}
handler rodou, defaultPrevented = false Not implemented: HTMLFormElement's requestSubmit() method

Neste teste, o evento de submit aconteceu e o handler rodou. Sem preventDefault(), o comportamento padrão continuou; a segunda linha apenas registra que o ambiente de teste não implementa a navegação. No navegador, ele pode recarregar ou mudar a página. A validação nativa com required ou pattern, porém, pode barrar o envio antes de onSubmit ser chamado.

Quando mostrar erros de validação no React?

Calcule se o valor é inválido a cada render, mas mostre a mensagem depois que o campo perder o foco ou após uma tentativa de envio. Assim você separa o campo está inválido? de já é hora de mostrar? e não interrompe quem ainda digita.

A resposta da segunda pergunta é um segundo estado, o de campos “tocados”, que recebe o nome do campo no onBlur. Esse evento ocorre quando o campo perde o foco; tocado significa que a pessoa já interagiu com ele:

jsx
import { useState } from 'react';

function validar(ficha) {
  const erros = {};
  if (ficha.nome.trim().length < 3) erros.nome = 'Escreva o nome completo do aluno.';
  if (!ficha.email.includes('@')) erros.email = 'E-mail do responsável inválido.';
  return erros;
}

function FichaMatricula() {
  const [ficha, setFicha] = useState({ nome: '', email: '' });
  const [tocado, setTocado] = useState({});
  const erros = validar(ficha);
  const visiveis = Object.fromEntries(Object.entries(erros).filter(([campo]) => tocado[campo]));
  console.log('render → erros visíveis:', JSON.stringify(visiveis));

  function atualizar(evento) {
    const { name, value } = evento.target;
    setFicha((anterior) => ({ ...anterior, [name]: value }));
  }

  function marcarTocado(evento) {
    setTocado((anterior) => ({ ...anterior, [evento.target.name]: true }));
  }

  function enviar(evento) {
    evento.preventDefault();
    setTocado({ nome: true, email: true });
    if (Object.keys(erros).length > 0) {
      console.log('submit bloqueado, erros:', JSON.stringify(erros));
      return;
    }
    console.log('enviado:', JSON.stringify(ficha));
  }

  return (
    <form onSubmit={enviar}>
      <input name="nome" value={ficha.nome} onChange={atualizar} onBlur={marcarTocado} />
      {visiveis.nome && <p>{visiveis.nome}</p>}
      <input name="email" value={ficha.email} onChange={atualizar} onBlur={marcarTocado} />
      {visiveis.email && <p>{visiveis.email}</p>}
      <button type="submit">Confirmar matrícula</button>
    </form>
  );
}

Digitando He letra por letra, saindo do campo, voltando para digitar o l, completando o nome e clicando em enviar sem preencher o e-mail:

render → erros visíveis: {} render → erros visíveis: {} render → erros visíveis: {} --- saiu do campo --- render → erros visíveis: {"nome":"Escreva o nome completo do aluno."} --- voltou a digitar: Hel --- render → erros visíveis: {} --- resto do nome de uma vez, e clique em enviar --- render → erros visíveis: {} submit bloqueado, erros: {"email":"E-mail do responsável inválido."} render → erros visíveis: {"email":"E-mail do responsável inválido."}

O comportamento que sai daí é exatamente o que se espera de um formulário educado. Enquanto a pessoa digita He, nada aparece — são três renders com o objeto vazio, contando a montagem. Ao sair do campo com o nome incompleto, o erro aparece, e repare que ele aparece sem nenhuma tecla nova: quem mudou foi o tocado, não a ficha. Ao voltar e digitar o l, o erro some na mesma tecla, porque erros é recalculado a cada render e não fica guardado em estado. E o submit marca todos os campos como tocados de uma vez, revelando o que faltava — o e-mail, que nunca chegou a ser visitado.

Repare que erros não é um useState. É um valor derivado do estado, calculado durante o render. Guardar erro em estado é a receita para ele ficar desatualizado em relação ao que está escrito no campo.

Leia o exemplo por responsabilidades. validar devolve um objeto de mensagens; tocado registra quando mostrá-las; visiveis filtra somente as mensagens liberadas. marcarTocado reage ao onBlur, enquanto enviar revela todos os campos, interrompe quando há erros e só chegaria ao último log com a ficha válida. No JSX, cada condição mostra seu parágrafo apenas quando existe texto.

Quando usar defaultValue e useRef num formulário React?

Use um campo não controlado quando o DOM puder guardar o valor e a aplicação só precisar lê-lo no submit. defaultValue define o começo; uma ref é o vínculo com o elemento do DOM, e useRef cria esse vínculo para acessá-lo depois.

Campo controlado tem um custo, e ele é mensurável. Montei dois componentes na mesma página e digitei as mesmas doze teclas — Helena Prado — em cada um: o controlado com value e onChange, o não controlado com defaultValue e uma referência.

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

let rendersControlado = 0;
let rendersNaoControlado = 0;

function BuscaControlada() {
  rendersControlado += 1;
  const [termo, setTermo] = useState('');
  return <input id="c" value={termo} onChange={(e) => setTermo(e.target.value)} />;
}

function BuscaNaoControlada() {
  rendersNaoControlado += 1;
  const campo = useRef(null);
  return (
    <form
      id="f"
      onSubmit={(evento) => {
        evento.preventDefault();
        console.log('o DOM guardou:', JSON.stringify(campo.current.value));
      }}
    >
      <input id="n" ref={campo} defaultValue="Helena" />
      <button id="b" type="submit">Buscar</button>
    </form>
  );
}

// depois das doze teclas em cada campo, os dois contadores são impressos
// e o botão Buscar é clicado
12 teclas digitadas em cada campo renders do componente controlado: 13 renders do componente não controlado: 1 o DOM guardou: "Helena Prado"

Treze renders contra um. Para um campo, isso é irrelevante — o React foi feito para isso e o navegador nem sente. Passa a importar quando o formulário tem trinta campos numa tela só, ou quando cada tecla dispara um cálculo caro.

O defaultValue diz “comece com este texto e depois se vire”. A partir daí quem manda é o elemento, e você lê o valor no momento do envio com useRef. É a mesma ideia do value do HTML puro.

No comparativo, os contadores sobem toda vez que cada componente executa. BuscaControlada chama seu setter a cada edição; BuscaNaoControlada entrega a ref ao input e só lê campo.current.value no submit. Por isso a saída mostra 13 renders no primeiro caso — montagem mais 12 teclas — e apenas um no segundo.

Só que “depois se vire” tem uma consequência quando os dados chegam de uma API, interface pela qual sistemas trocam informações:

jsx
import { useState } from 'react';

function EdicaoDeAluno() {
  const [aluno, setAluno] = useState(null);
  // setAluno({ nome: 'Helena Prado', turma: 'Inglês A2' }) quando a API responde

  return (
    <form>
      <input id="intocado" defaultValue={aluno?.nome ?? ''} />
      <input id="mexido" defaultValue={aluno?.turma ?? ''} />
    </form>
  );
}

Digitei Espanhol no segundo campo antes de a resposta chegar. Depois, a resposta chegou:

antes da API — intocado: "" antes da API — mexido: "Espanhol" depois da API — intocado: "Helena Prado" depois da API — mexido: "Espanhol"

O campo que ninguém tocou foi preenchido. O campo em que alguém já tinha digitado ignorou a resposta em silêncio — sem erro, sem aviso. Esse é o comportamento do HTML: o elemento fica “sujo” depois da primeira edição e para de acompanhar o valor padrão. Para tela de edição com dados que chegam depois, a resposta é campo controlado, ou uma key — prop especial de identidade — no formulário que force a remontagem quando os dados carregarem.

Nesse último exemplo, aluno começa em null, e ?. com ?? '' oferece texto vazio enquanto não há resposta. Os dois inputs recebem apenas um valor inicial. Quando setAluno traz a ficha, o campo intacto aceita o novo padrão no teste, mas o já editado conserva o que a pessoa escreveu, como a saída comprova.

situação escolha por quê
valor aparece em outro lugar da tela controlado só o estado alimenta os dois pontos
validação enquanto digita controlado a regra roda a cada render
filtrar lista conforme digita controlado a lista depende do valor
campo de arquivo (type="file") não controlado o valor é somente leitura no navegador
formulário grande e simples, lido só no submit não controlado um render em vez de um por tecla

Até aqui, você saiu do campo mínimo para uma ficha real: sabe escolher entre controle contínuo e leitura no submit, enviar sem navegação indesejada e evitar que dados tardios sobrescrevam silenciosamente a expectativa da interface.

O que estudar depois de formulários controlados no React?

Na ordem 11, estude useEffect, que sincroniza o componente com sistemas externos. Na ordem 12, aplique essa base ao consumo de API no React, incluindo espera, erro e sucesso.

A trilha de React mantém essa sequência visível no caminho completo.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • react
  • formulario
  • input
  • onchange
  • validacao

Perguntas frequentes

Preciso de uma biblioteca de formulário para começar?
Não. Para uma ficha de cinco ou seis campos, useState e um handler resolvem. Biblioteca começa a compensar quando o formulário tem campos dinâmicos, validação por schema e muitos campos por tela, porque aí o re-render de cada tecla passa a doer.
Posso usar o atributo required do HTML junto com o estado?
Pode, e é uma boa primeira barreira. Repare só na ordem: a validação nativa roda antes do evento de submit, e se ela barra o campo o seu handler nem chega a ser chamado — o preventDefault não tem o que cancelar. Fora isso, a mensagem do navegador não é estilizável e não cobre regra de negócio. Trate o required como rede de segurança, não como a validação.
Por que meu campo perde o foco a cada tecla?
Quase sempre porque o componente do campo está declarado dentro do componente pai. A cada render nasce um tipo novo, o React desmonta o antigo e monta outro, e o foco vai junto. Declare o componente fora.
Dá para deixar o campo controlado só depois que a API responder?
Dá, mas o valor inicial precisa ser string vazia, nunca null nem undefined. Se o estado começa indefinido e depois vira texto, o React avisa que o campo mudou de não controlado para controlado.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0 com React 19.2.8 e jsdom 30.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. React — <input> — react.dev
  2. React — <select> — react.dev
  3. MDN — HTMLInputElement — developer.mozilla.org

Continue por aqui