No artigo O que é o DOM e como o JavaScript interage com o HTML aprendemos a selecionar e modificar elementos do DOM. Mas um site estático que muda apenas quando o JavaScript carrega não é muito útil. O verdadeiro poder aparece quando o código reage ao usuário — quando ele clica, digita, envia um formulário, passa o mouse sobre um elemento, pressiona uma tecla.
Esse mecanismo de reação se chama evento. E entender eventos é entender como páginas web realmente funcionam.
O que é um evento?
Um evento é qualquer coisa que acontece no navegador — uma ação do usuário ou do próprio sistema. O navegador fica constantemente monitorando a página e, quando algo acontece, dispara um evento. Você pode "ouvir" esses eventos e executar código em resposta.
Exemplos de eventos:
- Usuário clicou em um botão
- Usuário digitou no campo de busca
- Formulário foi enviado
- Página terminou de carregar
- Mouse passou por cima de um elemento
- Tecla foi pressionada
- Janela foi redimensionada
addEventListener — a forma correta
A maneira moderna e recomendada de registrar um evento:
const botao = document.querySelector("#meuBotao");
botao.addEventListener("click", function() {
console.log("Botão clicado!");
});
// Com arrow function (mais comum no código moderno)
botao.addEventListener("click", () => {
console.log("Botão clicado!");
});
A sintaxe é sempre:
elemento.addEventListener("nomeDoEvento", funcaoCallback);
A função callback é executada toda vez que o evento ocorre no elemento.
O objeto Event
Quando um evento é disparado, o navegador passa automaticamente um objeto com informações sobre esse evento para o callback:
const botao = document.querySelector("#meuBotao");
botao.addEventListener("click", (evento) => {
console.log(evento.type); // "click"
console.log(evento.target); // o elemento que foi clicado
console.log(evento.target.id); // "meuBotao"
console.log(evento.timeStamp); // momento em que ocorreu
console.log(evento.clientX); // posição X do mouse
console.log(evento.clientY); // posição Y do mouse
});
Por convenção, esse parâmetro é chamado de event, evt ou simplesmente e.
Eventos de mouse
const caixa = document.querySelector(".caixa");
// Clique simples
caixa.addEventListener("click", () => {
console.log("Clicou!");
});
// Duplo clique
caixa.addEventListener("dblclick", () => {
console.log("Duplo clique!");
});
// Mouse entrou no elemento
caixa.addEventListener("mouseenter", () => {
caixa.classList.add("hover");
});
// Mouse saiu do elemento
caixa.addEventListener("mouseleave", () => {
caixa.classList.remove("hover");
});
// Mouse se moveu sobre o elemento
caixa.addEventListener("mousemove", (e) => {
console.log(`Posição: X=${e.clientX}, Y=${e.clientY}`);
});
// Botão do mouse pressionado
caixa.addEventListener("mousedown", () => {
caixa.classList.add("pressionado");
});
// Botão do mouse solto
caixa.addEventListener("mouseup", () => {
caixa.classList.remove("pressionado");
});
Eventos de teclado
const input = document.querySelector("#busca");
// Tecla pressionada (dispara continuamente se mantiver pressionada)
input.addEventListener("keydown", (e) => {
console.log(`Tecla pressionada: ${e.key}`);
console.log(`Código: ${e.code}`);
// Detectar teclas especiais
if (e.key === "Enter") {
console.log("Enter pressionado — executar busca!");
}
if (e.key === "Escape") {
input.value = "";
console.log("Campo limpo.");
}
});
// Tecla solta
input.addEventListener("keyup", (e) => {
console.log(`Digitado até agora: ${input.value}`);
});
// Combinações de teclas
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.key === "s") {
e.preventDefault(); // cancela o comportamento padrão (salvar página)
console.log("Salvando dados...");
}
});
Eventos de formulário
Os eventos de formulário são fundamentais para qualquer aplicação web:
const formulario = document.querySelector("#meuForm");
const campoNome = document.querySelector("#nome");
const campoEmail = document.querySelector("#email");
// Evento input — dispara a cada caractere digitado
campoNome.addEventListener("input", (e) => {
console.log(`Nome sendo digitado: ${e.target.value}`);
});
// Evento change — dispara quando o campo perde foco E o valor mudou
campoEmail.addEventListener("change", (e) => {
console.log(`E-mail confirmado: ${e.target.value}`);
});
// Evento focus — campo recebeu foco
campoNome.addEventListener("focus", () => {
campoNome.classList.add("ativo");
});
// Evento blur — campo perdeu foco
campoNome.addEventListener("blur", () => {
campoNome.classList.remove("ativo");
// Bom momento para validar o campo
if (campoNome.value.trim().length < 2) {
console.log("Nome muito curto.");
}
});
// Evento submit — formulário enviado
formulario.addEventListener("submit", (e) => {
e.preventDefault(); // SEMPRE previna o comportamento padrão (recarregar a página)
const dados = {
nome: campoNome.value.trim(),
email: campoEmail.value.trim(),
};
console.log("Formulário enviado:", dados);
});
O e.preventDefault() no submit é essencial — sem ele a página recarrega e você perde todos os dados.
Eventos da janela e documento
// Página completamente carregada (DOM + imagens + recursos)
window.addEventListener("load", () => {
console.log("Página totalmente carregada.");
});
// DOM pronto (sem esperar imagens)
document.addEventListener("DOMContentLoaded", () => {
console.log("DOM pronto. Pode manipular elementos.");
});
// Janela redimensionada
window.addEventListener("resize", () => {
console.log(`Nova largura: ${window.innerWidth}px`);
});
// Usuário rolou a página
window.addEventListener("scroll", () => {
const posicao = window.scrollY;
if (posicao > 300) {
document.querySelector("#voltarTopo").classList.remove("oculto");
}
});
// Antes de sair da página
window.addEventListener("beforeunload", (e) => {
e.preventDefault();
// Alguns navegadores exibem um diálogo de confirmação
});
Delegação de eventos — Event Delegation
Um padrão fundamental e muito eficiente: em vez de adicionar um listener para cada elemento filho, você adiciona um único listener no elemento pai e usa event.target para identificar quem foi clicado.
// ❌ Ineficiente — um listener para cada item
const itens = document.querySelectorAll(".item");
itens.forEach(item => {
item.addEventListener("click", () => {
item.classList.toggle("selecionado");
});
});
// ✅ Eficiente — um listener no pai
const lista = document.querySelector("#lista");
lista.addEventListener("click", (e) => {
// Verifica se o clique foi em um <li>
if (e.target.tagName === "LI") {
e.target.classList.toggle("selecionado");
}
});
A delegação de eventos é especialmente importante quando os elementos são criados dinamicamente — listeners adicionados antes da criação do elemento não funcionam, mas um listener no pai captura eventos de qualquer filho, inclusive os criados depois.
Removendo eventos
// Para remover um evento, a função deve ter nome (não pode ser anônima)
function aoClicar() {
console.log("Clicado!");
}
const botao = document.querySelector("#botao");
// Adiciona
botao.addEventListener("click", aoClicar);
// Remove — usa exatamente a mesma referência de função
botao.removeEventListener("click", aoClicar);
Exemplo completo — barra de busca ao vivo
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Busca ao Vivo</title>
<style>
body { font-family: sans-serif; max-width: 500px; margin: 2rem auto; padding: 1rem; }
input { width: 100%; padding: .75rem; font-size: 1rem; border: 2px solid #ddd; border-radius: 8px; }
input:focus { outline: none; border-color: royalblue; }
ul { list-style: none; padding: 0; margin-top: 1rem; }
li { padding: .5rem .75rem; border-bottom: 1px solid #eee; cursor: pointer; }
li:hover { background: #f0f4ff; }
.oculto { display: none; }
.destaque { background: yellow; }
#contador { color: gray; font-size: .9rem; margin-top: .5rem; }
</style>
</head>
<body>
<h2>🔍 Busca de Linguagens</h2>
<input type="text" id="busca" placeholder="Digite para filtrar...">
<p id="contador"></p>
<ul id="lista"></ul>
<script>
const linguagens = [
"JavaScript", "Python", "Java", "TypeScript",
"Go", "Rust", "PHP", "Ruby", "Swift", "Kotlin",
"C#", "C++", "Dart", "Scala", "Elixir"
];
const input = document.querySelector("#busca");
const lista = document.querySelector("#lista");
const contador = document.querySelector("#contador");
// Renderiza a lista com base em um filtro
function renderizarLista(filtro = "") {
const termo = filtro.toLowerCase().trim();
const filtradas = linguagens.filter(lang =>
lang.toLowerCase().includes(termo)
);
// Limpa a lista atual
lista.innerHTML = "";
// Cria novos itens
filtradas.forEach(lang => {
const li = document.createElement("li");
// Destaca o termo buscado dentro do texto
if (termo) {
// escapa os caracteres especiais de regex — sem isto, digitar
// "C++" monta /(c++)/ e o navegador lança SyntaxError
const termoRegex = termo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${termoRegex})`, "gi");
li.innerHTML = lang.replace(regex, '<mark>$1</mark>');
} else {
li.textContent = lang;
}
lista.appendChild(li);
});
// Atualiza o contador
contador.textContent = filtradas.length === 0
? "Nenhum resultado encontrado."
: `${filtradas.length} linguagem(ns) encontrada(s).`;
}
// Renderiza tudo ao carregar
renderizarLista();
// Filtra a cada caractere digitado
input.addEventListener("input", (e) => {
renderizarLista(e.target.value);
});
// Limpa com Escape
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
input.value = "";
renderizarLista();
input.focus();
}
});
// Delegação de evento na lista
lista.addEventListener("click", (e) => {
if (e.target.tagName === "LI" || e.target.tagName === "MARK") {
const lang = e.target.closest("li").textContent;
input.value = lang;
renderizarLista(lang);
}
});
</script>
</body>
</html>
Salve e abra no navegador. Você verá uma busca ao vivo funcionando com destaque de texto, contador e seleção por clique — tudo com eventos JavaScript puro.
Boas práticas com eventos
// ✅ 1. Sempre use addEventListener — nunca atributos HTML inline
// ❌ Ruim
// <button onclick="fazerAlgo()">Clique</button>
// ✅ Bom
botao.addEventListener("click", fazerAlgo);
// ✅ 2. Sempre chame preventDefault() no submit de formulários
formulario.addEventListener("submit", (e) => {
e.preventDefault();
// processar dados
});
// ✅ 3. Use delegação para listas e elementos dinâmicos
pai.addEventListener("click", (e) => {
if (e.target.matches(".item")) {
// agir sobre o item correto
}
});
// ✅ 4. Nomeie suas funções de callback para poder removê-las depois
function tratarClique() { /* ... */ }
botao.addEventListener("click", tratarClique);
botao.removeEventListener("click", tratarClique);
// ✅ 5. Cuidado com scroll e resize — use debounce para evitar
// excesso de chamadas (veremos isso em artigos futuros)
Tarefa para você
Crie uma página com um campo de texto e implemente:
- Exibir em tempo real quantos caracteres foram digitados
- Mudar a cor da borda para vermelho se passar de 50 caracteres
- Ao pressionar
Enter, exibir o texto em um parágrafo abaixo - Ao pressionar
Escape, limpar o campo - Exibir uma mensagem de boas-vindas quando o campo receber foco pela primeira vez (apenas uma vez — remova o listener depois)
Ver solução — os cinco comportamentos do campo, num listener por responsabilidade
// ---- index.html
// <div class="campo">
// <input type="text" id="entrada" maxlength="200"
// placeholder="Digite algo..." aria-describedby="contador">
// <p id="contador" class="contador">0 caractere(s)</p>
// <p id="aviso" class="aviso" role="status"></p>
// <div id="saida"></div>
// </div>
//
// <style>
// .contador.excedeu { color: #e53935; font-weight: 600; }
// #entrada.limite { border-color: #e53935; }
// </style>
// ---- app.js
const entrada = document.querySelector("#entrada");
const contador = document.querySelector("#contador");
const aviso = document.querySelector("#aviso");
const saida = document.querySelector("#saida");
const LIMITE = 50;
// ---------------------------------------------------------------
// 1 e 2 — contagem em tempo real e borda vermelha acima de 50
// ---------------------------------------------------------------
// `input` dispara a cada alteração do valor, inclusive colar com o
// mouse e ditado por voz. `keyup` perderia esses casos — e ainda
// contaria errado, porque dispara com Shift, setas e Ctrl.
entrada.addEventListener("input", () => {
const quantidade = entrada.value.length;
contador.textContent = `${quantidade} caractere(s)`;
// toggle com segundo argumento: liga se a condição for verdadeira,
// desliga se for falsa. Evita o par if/else só para trocar classe.
const excedeu = quantidade > LIMITE;
entrada.classList.toggle("limite", excedeu);
contador.classList.toggle("excedeu", excedeu);
});
// ---------------------------------------------------------------
// 3 e 4 — Enter exibe, Escape limpa
// ---------------------------------------------------------------
entrada.addEventListener("keydown", (evento) => {
// event.key é o caractere lógico ("Enter", "Escape", "a"). O antigo
// event.keyCode está obsoleto e é o motivo de tanto código com 13 e
// 27 mágicos espalhados.
if (evento.key === "Enter") {
evento.preventDefault(); // dentro de <form>, Enter submeteria
const texto = entrada.value.trim();
if (texto === "") return;
const paragrafo = document.createElement("p");
paragrafo.textContent = texto; // textContent: o usuário escreveu isto
paragrafo.classList.add("linha");
saida.appendChild(paragrafo);
entrada.value = "";
entrada.dispatchEvent(new Event("input")); // reaproveita o contador
}
if (evento.key === "Escape") {
entrada.value = "";
entrada.dispatchEvent(new Event("input"));
}
});
// ---------------------------------------------------------------
// 5 — boas-vindas no primeiro foco, uma vez só
// ---------------------------------------------------------------
// { once: true } faz o próprio navegador remover o listener depois de
// disparar. É mais confiável do que uma flag booleana, que alguém
// esquece de checar em algum caminho do código.
entrada.addEventListener(
"focus",
() => {
aviso.textContent = `Bem-vinda! O limite confortável é de ${LIMITE} caracteres.`;
setTimeout(() => (aviso.textContent = ""), 4000);
},
{ once: true }
);
// A forma manual, para quando a condição de remoção não é "a primeira
// vez" — repare que a função precisa ter nome para poder ser removida:
function saudarUmaVez() {
console.log("só uma vez");
entrada.removeEventListener("focus", saudarUmaVez);
}
// entrada.addEventListener("focus", saudarUmaVez);
// ---------------------------------------------------------------
// O detalhe que morde: removeEventListener precisa da MESMA função
// ---------------------------------------------------------------
// Cada arrow escrita no lugar cria uma função nova. Estas duas não são
// a mesma referência, então a remoção não remove nada:
entrada.addEventListener("blur", () => console.log("saiu"));
entrada.removeEventListener("blur", () => console.log("saiu")); // não faz nada
// O listener continua lá, e a cada re-render que adicionar outro, o
// mesmo evento passa a disparar duas, três, dez vezes. É a causa mais
// comum de "o clique executou várias vezes".
Guarde a referência da função (ou use { once: true } / AbortController) sempre que o listener puder ser removido. Arrow anônima em removeEventListener é uma linha que não faz nada e não avisa — o vazamento só aparece quando o evento dispara em dobro.
A delegação de eventos é o que separa o código que funciona com dez elementos do que funciona com dez mil: em vez de um ouvinte por item, um ouvinte no pai, e o event.target informa quem foi. Ao lado dela ficam duas peças que costumam ser mal usadas — o preventDefault, que só faz sentido quando existe um comportamento padrão a impedir, e o removeEventListener, que exige a mesma referência de função usada no registro, razão pela qual uma arrow escrita direto na chamada nunca poderá ser removida.
Fontes e Referências
- MDN Web Docs — Introduction to Events: https://developer.mozilla.org/pt-BR/docs/Learn/JavaScript/Building_blocks/Events
- MDN Web Docs — EventTarget.addEventListener: https://developer.mozilla.org/pt-BR/docs/Web/API/EventTarget/addEventListener
- MDN Web Docs — Event Reference: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Events
- MDN Web Docs — Event Delegation: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#event_delegation
- JavaScript.info — Introduction to browser events: https://javascript.info/introduction-browser-events
- JavaScript.info — Event Delegation: https://javascript.info/event-delegation
- Eloquent JavaScript, Cap. 15 — Handling Events: https://eloquentjavascript.net/15_event.html
- JavaScript & JQuery — Jon Duckett (Alta Books)
Exercícios
Exercício 1
O usuário cola um texto no campo com o botão direito do mouse, sem tocar no teclado. Quais dos três listeners disparam?
const campo = document.querySelector("#busca");
campo.addEventListener("input", () => console.log("A"));
campo.addEventListener("keyup", () => console.log("B"));
campo.addEventListener("change", () => console.log("C"));
Ver resposta
✓ Resposta: Dispara só o A, na hora da colagem. O input é o evento do valor mudou, não importa como: digitação, colar com o mouse, arrastar texto para dentro do campo, autopreenchimento do navegador, ditado por voz. O B não dispara porque nenhuma tecla foi solta — e é por isso que contador de caracteres feito em keyup ignora colagens e, pior, também dispara em Shift, setas e Ctrl, onde nada mudou. O C ainda não disparou: o change em campo de texto só acontece quando o campo perde o foco e o valor está diferente de quando ele o recebeu. Clique fora e o C aparece; clique fora sem ter alterado nada e ele não aparece. Regra prática: input para reagir enquanto se digita, change para confirmar um valor fechado.
Exercício 2
Esta era a linha do exemplo de busca ao vivo antes de ganhar o escape. Com a lista do artigo carregada, o usuário digita C++. O que acontece com a página?
const regex = new RegExp(`(${termo})`, "gi");
li.innerHTML = lang.replace(regex, "<mark>$1</mark>");
Ver resposta
✓ Resposta: A lista fica vazia e o contador congela na busca anterior. O filtro em si funciona: includes("c++") encontra o item "C++". O que quebra é a linha seguinte — o termo é interpolado direto na expressão regular, produzindo /(c++)/gi, e em JavaScript não existe quantificador possessivo: o segundo + não tem o que repetir, e o construtor lança SyntaxError: Invalid regular expression: Nothing to repeat. A exceção interrompe renderizarLista no meio, e o lista.innerHTML = "" já tinha rodado — daí a tela em branco. A correção é escapar o termo antes, com termo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"). Vale a lição geral: toda vez que texto do usuário vira sintaxe — regex, SQL, HTML, caminho de arquivo —, ele precisa ser escapado para a sintaxe de destino.
Exercício 3
O listener está na <ul> e o usuário clica bem em cima do trecho destacado, dentro do <mark>. O que sai em cada linha?
<ul id="lista">
<li>Java<mark>Script</mark></li>
</ul>
lista.addEventListener("click", (e) => {
console.log(e.target.tagName); // A
console.log(e.currentTarget.tagName); // B
console.log(e.target.matches("li")); // C
});
Ver resposta
✓ Resposta: A é "MARK", B é "UL" e C é false. O target é o elemento mais fundo onde o clique realmente aterrissou; o currentTarget é o elemento em que este listener está pendurado, e é ele que não muda ao longo da subida do evento. Aí está a armadilha central da delegação: testar e.target.tagName === "LI" funciona enquanto o item é texto puro e falha no dia em que alguém põe um <mark>, um <span> ou um ícone dentro dele — o clique passa a chegar no filho. A forma que não quebra é subir a partir do alvo: const item = e.target.closest("li"); if (item) { ... }. O closest começa no próprio elemento e sobe pelos ancestrais até achar quem casa com o seletor, devolvendo null se ninguém casar — daí o teste ser obrigatório, porque cliques no espaçamento da <ul> também acionam o listener.
Exercício 4
Depois destas quatro linhas, quantas vezes "oi" é impresso a cada clique?
botao.addEventListener("click", () => console.log("oi"));
botao.removeEventListener("click", () => console.log("oi"));
function saudar() { console.log("oi"); }
botao.addEventListener("click", saudar);
botao.addEventListener("click", saudar);
Ver resposta
✓ Resposta: Duas vezes. A remoção da segunda linha não remove nada: cada arrow escrita no código é uma função nova, e duas funções com o corpo idêntico continuam sendo objetos diferentes. O removeEventListener compara por referência, não por texto, então o primeiro listener segue registrado e conta 1. Já saudar, adicionado duas vezes com a mesma referência, é registrado uma só vez — o navegador ignora o par idêntico de tipo, função e opções — e conta mais 1. Vale corrigir a formulação comum de que "a função precisa ter nome para poder ser removida": o que precisa é da mesma referência. const saudar = () => {...} é uma arrow sem nome próprio e se remove sem problema, porque você guardou a referência numa variável. Quando isso incomoda, há duas saídas melhores: { once: true } no terceiro argumento, que faz o navegador remover sozinho após o primeiro disparo, e o AbortController, que desliga vários listeners de uma vez com um único controller.abort().
Exercício 5
Ao enviar o formulário, o objeto pisca no console e some, e a página parece voltar ao começo. O que está faltando — e mover o listener para o click do botão resolveria?
form.addEventListener("submit", (e) => {
const dados = { nome: campoNome.value.trim() };
console.log(dados);
});
Ver resposta
✓ Resposta: Falta e.preventDefault(). Sem ele, o navegador executa a ação padrão do formulário depois do listener: uma requisição para a própria URL, que recarrega a página. O código rodou e o console.log aconteceu de verdade — o que apagou a saída foi o recarregamento, e é por isso que o sintoma engana tanto (ligar o Preserve log do DevTools faz a mensagem sobreviver e mostra que nunca houve erro). Mover o listener para o click do botão não resolve: dentro de um <form>, um <button> sem type vale type="submit", então o clique continua submetendo. Forçar type="button" até impede o recarregamento, mas custa caro: some o envio pelo Enter e some a validação nativa dos campos required. Ouvir o submit e prevenir o padrão é o caminho que preserva a semântica do formulário e cancela apenas a navegação.