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
- Concept
- Key words in plain English
- Why this matters
- The instruction card — an analogy
- Proof: functions are values
- Higher-order functions
- Functions that return functions
- Callbacks — sync vs. async
- Event listeners are just callbacks
- Removing listeners correctly
- this inside a listener — a Lesson 5 callback
- See it live — DevTools
- Common misconceptions
- 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
typeofreports"function".) - Function reference is the function itself as a value, written without parentheses:
greet. Writinggreet()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.
addEventListeneris a method that registers an event listener on an element.removeEventListenerunregisters 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.
thisis 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. AbortControlleris a built-in object that makes asignal. When you callcontroller.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.
$0is a DevTools shortcut for the element currently selected in the Elements panel.
Why This Matters
- It's the mechanism underneath every
addEventListener, everysetTimeout, 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
removeEventListenerandthisinside listeners wrong is one of the most common real-world bugs in UI code.
The Instruction Card — An Analogy
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
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:
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 completerepeatTask 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.
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:
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 1200makeDiscount(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:
| Type | When it runs | Example |
|---|---|---|
| Synchronous callback | Immediately, before the outer function even returns | repeatTask above — callback(i) runs right there, inside the loop |
| Asynchronous callback | Later, after the current call stack has fully cleared | setTimeout(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.
Nothing different yet.
1console.log("before");2repeatTask(2, (r) => console.log("pass " + r));3console.log("after");
1console.log("before");2setTimeout(() => console.log("timer"), 0);3console.log("after");
Event Listeners Are Just Callbacks
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
// 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.
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.
// 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 gonethis Inside A Listener — A Lesson 5 Callback
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
Open any page and select an element
In the Elements panel, then switch to the Console.
Add a named listener
function log(){ console.log(this) }then$0.addEventListener('click', log)—$0refers to the currently selected element.Click the element and inspect this
Confirm it logs the element itself, not
window.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