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.
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:
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:
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>
);
}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.
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:
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.
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:
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:
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.
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.
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:
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:
// 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:
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.
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”:
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:
// o mesmo formulário, com a primeira linha do enviar removida
function enviar(evento) {
console.log('handler rodou, defaultPrevented =', evento.defaultPrevented);
}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:
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:
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.
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 é clicadoTreze 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:
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:
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.
Perguntas frequentes
Preciso de uma biblioteca de formulário para começar?
Posso usar o atributo required do HTML junto com o estado?
Por que meu campo perde o foco a cada tecla?
Dá para deixar o campo controlado só depois que a API responder?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- React — <input> — react.dev
- React — <select> — react.dev
- MDN — HTMLInputElement — developer.mozilla.org



