Lesson 12 · 14 min read

First-Class Functions, Callbacks and Event Listeners

Functions as values: passing them as arguments, returning them from other functions, sync vs async callbacks stepped side by side, and attaching and removing event listeners without leaking memory.

On this page

  1. Concept
  2. Key words in plain English
  3. Why this matters
  4. The instruction card — an analogy
  5. Proof: functions are values
  6. Higher-order functions
  7. Functions that return functions
  8. Callbacks — sync vs. async
  9. Event listeners are just callbacks
  10. Removing listeners correctly
  11. this inside a listener — a Lesson 5 callback
  12. See it live — DevTools
  13. Common misconceptions
  14. Interview questions

Concept

JavaScript treats functions as first-class citizens — a fancy way of saying a function can do everything any other value (a number, a string, an object) can do: get assigned to a variable, get stored inside an array or object, get passed into another function as an argument, and get returned out of a function. A callback is simply a function passed into another function, to be run later, by that other function. An event listener is just a callback registered with the browser, to be run later, whenever a particular event happens.

Key Words In Plain English

  • First-class function means a function is treated like any other value. You can store it in a variable, put it in an array, pass it in, and return it. (A function is a special kind of object, so typeof reports "function".)
  • Function reference is the function itself as a value, written without parentheses: greet. Writing greet() calls it instead.
  • Callback is a function that you give to other code so that it can call it later.
  • Higher-order function is a function that takes a function as an argument, returns a function, or both. Difference in one line: a callback is the function that is passed in; a higher-order function is the one that receives it.
  • Synchronous means it runs right now and the next line waits. Asynchronous means it is scheduled to run later while the next lines continue.
  • Anonymous function is a function with no name, written in place, such as () => console.log(1). Arrow function is the short () => ... way to write a function.
  • Event is a signal that something happened, such as a click or a key press. Event listener is a callback registered to run when a given event happens on a given element.
  • addEventListener is a method that registers an event listener on an element. removeEventListener unregisters one.
  • Closure is a function that keeps access to the variables of the place where it was created (Lesson 10).
  • Function factory is a function that builds and returns other functions.
  • this is a special value inside a function that points to the object the function is working with. For a regular function, how the function is called decides it (Lesson 5).
  • Rest parameter (...args) collects all extra arguments into an array. Spread (fn(...args)) does the reverse and passes an array as separate arguments.
  • AbortController is a built-in object that makes a signal. When you call controller.abort(), everything that was given that signal is cancelled. Here, the listeners are removed.
  • Middleware is a function placed between a request and its handler to add steps such as logging. Decorator is a function that wraps another function to add behaviour. Debounce waits until calls stop before running once. Throttle runs at most once per time period.
  • $0 is a DevTools shortcut for the element currently selected in the Elements panel.

Why This Matters

  • It's the mechanism underneath every addEventListener, every setTimeout, and every function factory from Lesson 10 — they all rely on functions being passable, storable values.
  • It explains why a function can be “handed off” to run somewhere else, later, without losing access to the data it needs — that's the closure link from Lesson 10, doing its job.
  • Getting removeEventListener and this inside listeners wrong is one of the most common real-world bugs in UI code.

The Instruction Card — An Analogy

A written instruction you can hand to anyone

A guest writes an instruction on a card: “When the fire alarm rings, evacuate guests from Floor 3.” That card is not the action itself — it's a portable set of instructions. The guest can hand it to the front desk. The front desk can hand it to security. Security can pin it to a board and only act on it later, whenever the alarm actually rings.

That card is exactly what a function is in JavaScript — a self-contained, portable set of instructions you can pass around freely. A callback is that card handed to someone else to read later. An event listener is that card pinned specifically to the fire alarm, to be read the moment it rings.

Proof: Functions Are Values

functions-as-values.js
function greetGuest() {
  console.log("Welcome to the front desk!");
}

console.log(typeof greetGuest); // "function" — it's a value, with a type, like anything else

const alsoGreet = greetGuest; // assigned to another variable — no () means "don't call it, just copy the reference"
alsoGreet(); // Welcome to the front desk!

const staffActions = [greetGuest, () => console.log("Checking in guest...")];
staffActions[1](); // Checking in guest...

greetGuest (no parentheses) refers to the function itself, as a value. greetGuest() (with parentheses) calls it and refers to whatever it returns. Mixing these two up is one of the most common bugs when passing callbacks — more on this below.

Higher-Order Functions

A higher-order function is any function that either accepts another function as an argument, or returns a function. You've already met one — Lesson 10's createCounter is higher-order because it returns a function. Here's one that accepts a function:

repeat-task.js
function repeatTask(times, callback) {
  for (let i = 1; i <= times; i++) {
    callback(i);
  }
}

repeatTask(3, (round) => {
  console.log(`Cleaning pass ${round} complete`);
});
// Cleaning pass 1 complete
// Cleaning pass 2 complete
// Cleaning pass 3 complete

repeatTask doesn't know or care what callback actually does — its only job is to call it, at the right time, with the right argument. This separation — “when to run” decided by one function, “what to run” decided by whoever calls it — is the entire point of accepting a function as an argument.

repeatTask(3, callback)
holds a reference to callback
for (i = 1; i <= 3; i++)
callback(i)
call 1 →call 2 →call 3 →
callback (passed in)
console.log(`pass $${round} complete`)
repeatTask doesn't know what the callback does — it just holds the reference and calls it, three times, whenever its loop reaches that line. · higher-order function

Functions That Return Functions

The other half of “higher-order” is returning a function. Because a function is a value, a function can build one and hand it back — and thanks to closures (Lesson 10), the returned function keeps access to the variables of the call that made it. That turns one general function into a small factory for specialised ones:

returning-functions.js
function makeDiscount(percent) {
  return function (price) {
    return price - (price * percent) / 100;
  };
}

const festiveSale = makeDiscount(20); // a brand-new function, remembering percent = 20
const staffRate = makeDiscount(50);   // another one, remembering percent = 50

console.log(festiveSale(1000)); // 800
console.log(staffRate(1000));   // 500

// The same idea, used to wrap any function with extra behaviour:
function withLogging(fn) {
  return function (...args) {
    console.log("calling with", args);
    return fn(...args);
  };
}
const loggedDiscount = withLogging(festiveSale);
loggedDiscount(1500); // logs "calling with [1500]", returns 1200

makeDiscount(20) and makeDiscount(50) are two separate calls, so each gets its own Variable Environment (Lesson 4) and each returned function closes over its own percent. withLogging goes one step further: it takes a function and returns a new one that wraps it — the pattern behind middleware, retry helpers, debounce and throttle, and most “decorators” you will meet in real code.

Callbacks — Sync vs. Async

Not all callbacks run at the same moment relative to the code around them:

TypeWhen it runsExample
Synchronous callbackImmediately, before the outer function even returnsrepeatTask above — callback(i) runs right there, inside the loop
Asynchronous callbackLater, after the current call stack has fully clearedsetTimeout(callback, 1000) — the call stack moves on immediately; callback runs after the delay

Both are callbacks — a function handed to another function to be run on its behalf. What changes is only when that run actually happens, which is a preview of the event loop machinery in Lesson 14.

step 1 / 7
Script running
Line 1 — both print “before”

Nothing different yet.

synchronous · repeatTask
1console.log("before");
2repeatTask(2, (r) => console.log("pass " + r));
3console.log("after");
call stack (top first)
console.log
global()
› before
asynchronous · setTimeout
1console.log("before");
2setTimeout(() => console.log("timer"), 0);
3console.log("after");
call stack (top first)
console.log
global()
› before
Two callbacks, stepped together. A synchronous callback runs inside the call that received it; an asynchronous one runs after the whole script has finished. · interactive

Event Listeners Are Just Callbacks

listener.js
const checkoutButton = document.querySelector("#checkout-button");

function handleCheckout() {
  console.log("Guest checked out");
}

checkoutButton.addEventListener("click", handleCheckout);

addEventListener is a higher-order function too — you're handing it a callback (handleCheckout), and the browser holds onto that reference and invokes it later, whenever a click event actually happens on that button. Nothing new is happening here conceptually — it's repeatTask's pattern, except the browser decides when to call your function instead of a for loop deciding.

Removing Listeners Correctly

remove-listener.js
// Works — same function reference passed to both calls
checkoutButton.addEventListener("click", handleCheckout);
checkoutButton.removeEventListener("click", handleCheckout);

// Does NOT work — two different anonymous functions, even though they look identical
checkoutButton.addEventListener("click", () => console.log("Guest checked out"));
checkoutButton.removeEventListener("click", () => console.log("Guest checked out"));

removeEventListener needs the exact same function reference that was passed to addEventListener — not a function that merely does the same thing. Two separately written arrow functions are two separate values in memory, even with identical code inside them, so the second one never actually removes anything. Always store the callback in a named variable or named function if you'll ever need to remove it.

Named function — removed ✓
add("click", handleCheckout) ─┐
remove("click", handleCheckout) ┘ same reference
fn#1 handleCheckout
button listeners: (empty) ✓
Two arrow functions — not removed ✗
add("click", () => …) → creates fn#1
remove("click", () => …) → creates fn#2
fn#1 (registered)
fn#2 (never registered)
button listeners: [fn#1] — still firing, and still holding its closure in memory
The button keeps a list of function references. removeEventListener only removes an entry if it's handed the very same reference — a new arrow function is a new object, however similar it looks. · references in memory

Modern browsers also give you two ways to clean up without keeping a reference around: the once option removes a listener automatically after its first call, and an AbortController signal removes every listener registered with it in one go — anonymous ones included. This matters beyond tidiness: a listener that is never removed keeps its callback, and everything that callback's closure references, alive in memory for as long as the element exists.

clean-removal.js
// Run once, then remove itself automatically
checkoutButton.addEventListener("click", handleCheckout, { once: true });

// Remove many listeners at once, anonymous ones included
const controller = new AbortController();
checkoutButton.addEventListener("click", () => console.log("A"), { signal: controller.signal });
window.addEventListener("resize", () => console.log("B"), { signal: controller.signal });
controller.abort(); // both listeners are gone

this Inside A Listener — A Lesson 5 Callback

this-in-listener.js
checkoutButton.addEventListener("click", function () {
  console.log(this); // the button element itself — a regular function's this is set by how it's called
});

checkoutButton.addEventListener("click", () => {
  console.log(this); // whatever `this` was in the surrounding scope when the listener was defined — NOT the button
});

This is Lesson 5's rule about this, applied to a real, common situation: a regular function passed as a listener gets this set to the element the event fired on. An arrow function never gets its own this — it keeps whatever this its surrounding scope had (in a classic script that is the global object window, per Lesson 5; in a module it is undefined), which is almost never what you want inside a listener that needs to reference the clicked element.

See It Live — DevTools

  1. Open any page and select an element

    In the Elements panel, then switch to the Console.

  2. Add a named listener

    function log(){ console.log(this) } then $0.addEventListener('click', log) — $0 refers to the currently selected element.

  3. Click the element and inspect this

    Confirm it logs the element itself, not window.

  4. Try removeEventListener with a fresh arrow function

    $0.removeEventListener('click', () => console.log(this)) — click again and notice the original listener is still very much alive.

Common Misconceptions

“Passing greetGuest() as a callback passes the function.” It doesn't — the parentheses call it immediately and pass whatever it returns. To pass the function itself, leave the parentheses off: greetGuest, not greetGuest().

“Every callback is asynchronous.” No — repeatTask's callback runs synchronously, immediately, inside the loop. Only callbacks handed to things like setTimeout, promises, or browser events are asynchronous.

“removeEventListener with the ‘same-looking’ function should work.” It needs the identical reference, not identical-looking code. Two separate function expressions are two separate values.

Interview Questions

What does it mean for functions to be "first-class" in JavaScript?

Functions can be assigned to variables, stored in data structures, passed as arguments, and returned from other functions — treated as ordinary values, just like numbers or strings.

What is a higher-order function?

A function that either accepts another function as an argument, or returns a function (or both).

What is the difference between a synchronous and an asynchronous callback?

A synchronous callback runs immediately, within the same call, before the outer function returns. An asynchronous callback runs later, after the current call stack has cleared — like a setTimeout or event listener callback.

Why does removeEventListener sometimes silently fail to remove a listener?

Because it was called with a function reference that isn't the exact same one used in addEventListener — usually a newly written anonymous function that only looks identical.

Why does this behave differently between a regular function and an arrow function used as a listener?

A regular function's this is set dynamically by how it's called — the browser sets it to the element the event fired on. An arrow function has no this of its own; it keeps whatever this its surrounding scope had at definition time.

Give an example of a function that returns a function, and explain why it works.

A factory like makeDiscount(percent) that returns (price) => price - price * percent / 100. Each call creates a new execution context with its own percent, and the returned function keeps it through a closure — so makeDiscount(20) and makeDiscount(50) produce two independent functions.

How can you remove an anonymous event listener?

You can't with removeEventListener, because you have no reference to it. Register it with an AbortController's signal and call controller.abort(), or use { once: true } if it should only ever fire once.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series