Skip to content
aviral gupta

// B6.3 · ~30 min · Beginner

Listeners and the event object

After this lesson your page reacts to clicks, typing and form submits: you add and remove listeners, read what the event object tells you, and handle a form without a reload.

Lesson 3 of 5 in B6 The DOM and events

You will be able to

  • Add listeners with addEventListener instead of inline on… attributes, and remove them with removeEventListener or the once option
  • Read the event object: type, target, currentTarget, and key for keyboard events
  • Handle input and submit events: read input.value, call preventDefault(), and avoid the form.elements.item trap
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B6.2. The list holds one item. What does this print?

    const list = document.querySelector('#list');
    const li = document.createElement('li');
    li.textContent = 'b';
    list.prepend(li);
    console.log(list.textContent, list.children.length);
  2. Predict · Activity 2 of 7

    Predict before you read on. A listener counts input events. What does this print?

    const field = document.querySelector('#name');
    let n = 0;
    field.addEventListener('input', () => {
      n += 1;
    });
    field.value = 'cat';
    console.log(n, field.value);
  3. Practice · Activity 3 of 7

    Fill in the method that makes count run on every click.

    button.____('click', count);
    button.('click', count);
  4. Practice · Activity 4 of 7

    Match each property of the event object to what it holds.

  5. Practice · Activity 5 of 7

    The button is <button id="buy"><span id="icon">*</span> Buy</button>, and the click lands on the icon. What does this print?

    const buy = document.querySelector('#buy');
    buy.addEventListener('click', (event) => {
      console.log(event.target.id, event.currentTarget.id);
    });
    document.querySelector('#icon').click();
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The program removes the listener, or so it seems. What does it print?

    const button = document.querySelector('#like');
    let n = 0;
    button.addEventListener('click', () => {
      n += 1;
    });
    button.removeEventListener('click', () => {
      n += 1;
    });
    button.click();
    console.log(n);
  7. Apply · Activity 7 of 7

    Mini-task: a vote button and a greeting form. Write an index.html with a button #vote, a span #votes showing 0, and a form #name-form with an input named who. In main.js, each click on the button adds one and shows the count. On submit, call event.preventDefault(), read form.elements.who.value, trim it, and show "Hello, " plus the name in a paragraph #hello with textContent. Try it through a local web server.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A like button and a search form

The page has a like button with a counter, a search form and a log paragraph. main.js adds a click listener that counts, a second one with { once: true }, a submit listener that calls preventDefault() and shows the search, and a keydown listener on the field. At the end the script presses its own button twice, submits the form with requestSubmit() and sends an Escape key, the way the exercise tests do, so you can see what each listener receives.

main.js

const like = document.querySelector('#like');
const likes = document.querySelector('#likes');
const form = document.querySelector('#search');
const query = form.elements.query;
const log = document.querySelector('#log');

let count = 0;
like.addEventListener('click', (event) => {
  count += 1;
  likes.textContent = String(count);
  console.log(event.type, event.target.id, count);
});

// once: this listener is removed after its first call.
like.addEventListener('click', () => console.log('thanks for the first like'), { once: true });

form.addEventListener('submit', (event) => {
  event.preventDefault(); // handle it here: no page reload
  log.textContent = 'Searching for ' + query.value.trim();
  console.log(event.type, event.target.id, event.currentTarget === form);
});

query.addEventListener('keydown', (event) => {
  console.log(event.type, event.key);
});

// The script tries its own page, the way the exercise tests do.
like.click();
like.click();
query.value = '  cats ';
form.requestSubmit();
query.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
console.log('likes shown:', likes.textContent);

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Listeners</title>
</head>
<body>
  <button id="like" type="button">Like</button> <span id="likes">0</span>
  <form id="search">
    <input name="query" aria-label="Search">
    <button>Search</button>
  </form>
  <p id="log">Nothing searched yet.</p>
  <script type="module" src="main.js"></script>
</body>
</html>

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Output

click like 1
thanks for the first like
click like 2
submit search true
keydown Escape
likes shown: 2
<!-- #log -->
<p id="log">Searching for cats</p>
  • Both click listeners ran on the first click, in the order they were added; the once listener did not run again.
  • For submit, target and currentTarget are both the form, so currentTarget === form is true.
  • event.key names the key: Escape.
  • The search text was trimmed and shown with textContent; preventDefault() kept the page.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

A live status preview

As the user types into #status, the page should update on every input event: #preview shows the text exactly as typed, as text, and #count shows its length out of 40, such as "5 / 40". When the text is longer than 40 characters, #count gets the class over; when it is short enough again, the class goes. The starter listens for change, which fires only when the field loses focus, and updates only the preview.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: nothing changes, because the tests send input events and the starter listens for change.

  2. Hint 2

    Listen for 'input', read field.value once into a constant, and set both textContent values from it.

  3. Hint 3

    Use an if...else with text.length > 40 and count.classList.add('over') or remove('over').

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const field = document.querySelector('#status');
const count = document.querySelector('#count');
const preview = document.querySelector('#preview');

field.addEventListener('input', () => {
  const text = field.value;
  preview.textContent = text;
  count.textContent = text.length + ' / 40';
  if (text.length > 40) {
    count.classList.add('over');
  } else {
    count.classList.remove('over');
  }
});
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const field = document.querySelector('#status');
const count = document.querySelector('#count');
const preview = document.querySelector('#preview');

// TODO: update the preview and the count on every input event.
field.addEventListener('change', () => {
  preview.textContent = field.value;
});

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const type = (page, text) => {
  const field = page.document.querySelector('#status');
  field.value = text;
  field.dispatchEvent(new page.window.Event('input', { bubbles: true }));
};

test('Typing updates the preview', async () => {
  const page = await loadPage();
  type(page, 'Hello');
  const text = page.document.querySelector('#preview').textContent;
  assert.equal(text, 'Hello', 'after typing Hello the preview shows ' + JSON.stringify(text));
});

test('The count shows the length out of 40', async () => {
  const page = await loadPage();
  type(page, 'Hello');
  const text = page.document.querySelector('#count').textContent;
  assert.equal(text, '5 / 40', 'after typing Hello the count shows ' + JSON.stringify(text));
});

test('Markup in the text is shown as text', async () => {
  const page = await loadPage();
  type(page, '<b>hi</b>');
  const preview = page.document.querySelector('#preview');
  assert.equal(preview.children.length, 0, 'the preview built ' + preview.children.length + ' element(s) from the text');
});

test('Text over 40 characters gives the count the class over', async () => {
  const page = await loadPage();
  type(page, 'x'.repeat(41));
  const over = page.document.querySelector('#count').classList.contains('over');
  assert.equal(over, true, 'after 41 characters the count has no class over');
});

test('Shorter text removes the class over again', async () => {
  const page = await loadPage();
  type(page, 'x'.repeat(41));
  type(page, 'ok');
  const over = page.document.querySelector('#count').classList.contains('over');
  assert.equal(over, false, 'after shortening the text the count still has the class over');
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Status</title>
</head>
<body>
  <label for="status">Your status</label>
  <input id="status">
  <p id="count">0 / 40</p>
  <p id="preview"></p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Exercise 2 of 2

A form that adds to a list

On submit, the form should add the trimmed text of its field entry to #list as a new li, then clear the field. An empty or blank entry adds nothing. The listener must call event.preventDefault(), so a real page would not reload. Pressing Escape in the field clears it; other keys leave it alone. The starter adds untrimmed text, adds blanks, never clears the field or cancels the submit, and ignores Escape.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Run the tests: " tea " is added with its spaces, blanks are added, and the submit is not cancelled.

  2. Hint 2

    Give the listener the parameter event and call event.preventDefault() first. Trim the value, and return early when it is ''.

  3. Hint 3

    For Escape: entry.addEventListener('keydown', (event) => { … }) and compare event.key with 'Escape'.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const form = document.querySelector('#add');
const list = document.querySelector('#list');
const entry = form.elements.entry;

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const text = entry.value.trim();
  if (text === '') return;
  const li = document.createElement('li');
  li.textContent = text;
  list.append(li);
  entry.value = '';
});

entry.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') entry.value = '';
});
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

const form = document.querySelector('#add');
const list = document.querySelector('#list');

// TODO: cancel the submit, trim, skip blanks, clear the field after adding.
form.addEventListener('submit', () => {
  const li = document.createElement('li');
  li.textContent = form.elements.entry.value;
  list.append(li);
});

// TODO: pressing Escape in the field clears it.

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {loadPage} from './learnrun.js';

const add = (page, text) => {
  page.document.querySelector('#add').elements.entry.value = text;
  page.document.querySelector('#add').requestSubmit();
};
const shown = (page) => [...page.document.querySelectorAll('#list li')].map((li) => li.textContent);
const field = (page) => page.document.querySelector('#add').elements.entry;

test('Submitting adds the text to the list', async () => {
  const page = await loadPage();
  add(page, 'milk');
  assert.deepEqual(shown(page), ['milk'], 'the list shows ' + JSON.stringify(shown(page)));
});

test('The text is trimmed and the field is cleared', async () => {
  const page = await loadPage();
  add(page, '  tea ');
  assert.deepEqual(shown(page), ['tea'], 'the list shows ' + JSON.stringify(shown(page)));
  assert.equal(field(page).value, '', 'after adding, the field holds ' + JSON.stringify(field(page).value));
});

test('A blank entry adds nothing', async () => {
  const page = await loadPage();
  add(page, '   ');
  assert.deepEqual(shown(page), [], 'the list shows ' + JSON.stringify(shown(page)));
});

test('The submit is cancelled with preventDefault()', async () => {
  const page = await loadPage();
  const form = page.document.querySelector('#add');
  let cancelled = false;
  form.addEventListener('submit', (event) => {
    cancelled = event.defaultPrevented;
  });
  add(page, 'milk');
  assert.equal(cancelled, true, 'the submit event was not cancelled');
});

test('Escape clears the field', async () => {
  const page = await loadPage();
  field(page).value = 'oops';
  field(page).dispatchEvent(new page.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
  assert.equal(field(page).value, '', 'after Escape the field holds ' + JSON.stringify(field(page).value));
});

test('Other keys leave the field alone', async () => {
  const page = await loadPage();
  field(page).value = 'tea';
  field(page).dispatchEvent(new page.window.KeyboardEvent('keydown', { key: 'a', bubbles: true }));
  assert.equal(field(page).value, 'tea', 'after the key a the field holds ' + JSON.stringify(field(page).value));
});

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Shopping</title>
</head>
<body>
  <form id="add">
    <input name="entry" aria-label="New item">
    <button>Add</button>
  </form>
  <ul id="list"></ul>
  <p>Press Escape to clear the field.</p>
  <script type="module" src="main.js"></script>
</body>
</html>

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

To run the page on your computer, open index.html through a local web server, not as a file: browsers do not run module scripts from file:// addresses. For the checks, install jsdom first (the commands below).

Run the checks (needs learnrun.js in the same folder):

npm install jsdom@28.1.0
node --test
Download learnrun.js

Common mistakes

Calling the listener instead of passing it

function save(event) {
  console.log('saved by', event.target.id);
}
document.querySelector('#save').addEventListener('click', save());

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'target')

Why, and the fix

save() with parentheses calls the function at once, while the page loads, with no event, so event is undefined. addEventListener should get the function itself, to call later with the event: addEventListener('click', save). Had save not touched event, there would be no error, and the click would simply do nothing. Firefox and Safari word this message differently.

Reading a field named item through form.elements

const form = document.querySelector('#add');
// The field is <input name="item">: read what it starts with.
const start = form.elements.item.value.trim();
console.log(start);

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'trim')

Why, and the fix

form.elements is a collection, and every collection has a method item(index). That method wins over a field named item, so form.elements.item is a function, and its value is undefined. Inside a listener, li.textContent = form.elements.item.value would even fail silently and show nothing. Use form.elements.namedItem('item'), or give the field another name, such as entry.

Adding a listener to a NodeList

const buttons = document.querySelectorAll('button');
buttons.addEventListener('click', () => console.log('clicked'));

What Node.js prints

TypeError: buttons.addEventListener is not a function

Why, and the fix

Listeners belong to single elements; a NodeList has no addEventListener. Add one to each button in a loop: for (const button of buttons) button.addEventListener('click', …). Lesson B6.4 shows a better way for many similar elements: one listener on their common parent.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

addEventListener, not on… attributes

button.addEventListener("click", handler) asks the browser to call handler each time the button is clicked. Pass the function itself, handler, not handler(): the parentheses would call it once, right away. One element can have several listeners for one event, and they run in the order they were added; the same function is not added twice. MDN says never to use HTML attributes such as onclick="…": they mix JavaScript into the HTML and many sites' security settings block them. removeEventListener("click", handler) needs the same function, so give a listener a name if you will remove it. { once: true } removes the listener after its first call.

The event object

The browser calls your listener with one argument, the event object, usually called event. event.type is the event's name, such as "click" or "submit". event.target is the element the event happened on. event.currentTarget is the element whose listener is running; the two differ when the click lands on something inside, such as an icon in a button, which lesson B6.4 explains as bubbling. Keyboard events add event.key, the key pressed: "a", "Enter", "Escape". In tests and in your own scripts, element.click() clicks, and dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })) sends a key.

Input and submit

input fires each time the user changes a field's value; input.value is always a string, so a number field gives "7". Setting value from code fires no event. A form fires submit when it is sent, by its button, by Enter, or by form.requestSubmit(). Without event.preventDefault() the browser would send the form and load a new page, so a script that handles the form itself calls it first. Read fields by name with form.elements.entry. One trap: form.elements.item is the collection's item() method, not your field, so .value gives undefined; name fields something else, or use form.elements.namedItem("item").

Sources

Last reviewed October 4, 2026