Functions, Callbacks, and Closures in JavaScript
A JavaScript function is best pictured as a packed lunchbox. It holds instructions you can carry somewhere and open later, and it quietly packs a key to the cupboard it came from. That key is what lets a function you defined in one place still reach the right variables when something else opens it three seconds later.
This guide covers the two ways to declare a function, why arrow functions handle this differently, default and rest parameters, callbacks, the closures that make callbacks remember anything, and functions that take or return other functions.
Declarations Against Expressions
A declaration starts with the word function. A function expression is a function written where a value is expected, which often means assigning it to a variable but can equally mean passing it straight to something else. The difference that matters first is when each becomes usable:
console.log(double(4));
function double(n) {
return n * 2;
}
triple(4);
const triple = (n) => n * 3; // node output:
// 8
// ReferenceError: Cannot access 'triple' before initialization The declaration worked before its own line, because declarations are hoisted: the engine registers the whole function when it first reads the file. The const is hoisted too, but stays unusable until execution reaches its line, which is the error you see above.
In practice this rarely decides anything, because most code calls functions after defining them anyway. The real reason teams reach for arrow functions is the next section.
Arrow syntax has two forms, and reading other people's code means recognising both. With a block body in braces you write return yourself; with a concise body you leave it out and the expression is returned:
const triple = (n) => {
return n * 3;
};
const tripleShort = (n) => n * 3;
console.log(triple(4), tripleShort(4)); // node output:
// 12 12 Both are the same function written two ways. The concise form is why array callbacks are usually one line, and the block form is what you want as soon as the body needs a variable or an early return.
An arrow is not only a shorter spelling. It has no this of its own, no arguments object, cannot be called with new, and cannot be a generator. The next section is about the first of those, which is the one you meet daily.
Arrow Functions and this
In an ordinary function, this is decided by the call form: the object before the dot, whatever call, apply, or bind supplies, the new object under new, or nothing at all for a plain call. An arrow function has no this of its own and borrows the one from the code around it:
const cart = {
items: ["lamp", "desk"],
countNormal() {
return this.items.length;
},
countArrow: () => {
return typeof this;
},
};
console.log(cart.countNormal());
console.log(cart.countArrow()); // node output, running this file as an ES module:
// 2
// undefined The normal method saw cart because cart appeared before the dot at the call site. The arrow function looked outward instead and found the module's top-level this, which is undefined in an ES module and something else again in a CommonJS file or a classic browser script. The point is that the call site never decides it.
An arrow can still end up with the object, as long as it inherited it lexically. An arrow created inside a normal method, or written as a class field, captures the instance and keeps it.
So the rule is not "arrow functions are better", and it is not "arrows for callbacks, normal functions for methods" either. Use an arrow when the surrounding this is the one you want, or when this is irrelevant. Use a normal method when the receiver at the call site is the whole point.
The same rule explains a common bug in the other direction. A normal method handed over as a bare callback loses its receiver whenever the API calls it plainly, which most do, though some accept a thisArg or invoke it as a method instead:
const size = cart.countNormal;
size(); // node output:
// TypeError: Cannot read properties of undefined (reading 'items') Nothing appeared before a dot when size() ran, so this was undefined and reading .items from it failed. Pass () => cart.countNormal() instead, or attach the object permanently with cart.countNormal.bind(cart).
Default and Rest Parameters
A default value fills in for a parameter that was not passed, and a rest parameter collects everything left over into an array:
function makeTag(name, attributes = {}, ...children) {
return [name, JSON.stringify(attributes), children.length];
}
console.log(makeTag("p"));
console.log(makeTag("ul", { id: "list" }, "a", "b", "c")); // node output:
// [ 'p', '{}', 0 ]
// [ 'ul', '{"id":"list"}', 3 ] The default applies when the argument is missing or explicitly undefined, and not when it is null, 0, or an empty string. That is narrower than ?? from the values and equality guide, which also falls back on null, and narrower still than the looser || rule people often expect.
Unlike some languages, a JavaScript default is evaluated at every call, so function log(entries = []) gets a fresh array each time rather than one shared array that accumulates. The rest parameter must be last, and there can only be one.
Callbacks
A callback is a function handed to something else for that code to call. This is not a special language feature, it is what happens when you pass a function as an argument, which JavaScript allows because functions are ordinary values:
const prices = [12, 5, 30];
const doubled = prices.map((price) => price * 2);
console.log(doubled); // node output:
// [ 24, 10, 60 ] The lunchbox picture holds here. You pack the instructions, hand the box to map, and map opens it once per element. You never call the arrow function yourself, which is why passing fn() instead of fn is such a common mistake: the parentheses open the box immediately and hand over whatever fell out.
The same shape drives event listeners, timers, array methods, and the promise callbacks covered elsewhere. The timing varies: map, filter, and forEach call yours immediately, during their own call, while a timer or a listener calls it much later. The shape is identical either way.
The part that catches people is that the caller decides the arguments, not you. map passes three of them to every callback, and only the first is the element:
console.log(["1", "5", "10"].map(Number));
console.log(["1", "5", "10"].map(parseInt)); // node output:
// [ 1, 5, 10 ]
// [ 1, NaN, 2 ] Both lines look like they convert three strings to numbers. The second one received the index as a second argument, and parseInt reads its second argument as a number base, so it parsed "5" in base 1 and "10" in base 2. Number receives the extra arguments too and ignores them, which is the only reason the first line works.
The lesson generalises past this one example. Before passing an existing function straight through as a callback, check what extra arguments the caller supplies, and wrap it in (value) => existing(value) when you only want the first one.
Closures
A closure is the key packed inside the lunchbox. A function keeps a live link to the variables that were in scope where it was written, not a copy of their values, so it reads whatever they hold at the moment it runs and can update them too:
function makeCounter() {
let count = 0;
return () => {
count += 1;
return count;
};
}
const next = makeCounter();
console.log(next(), next(), next());
const other = makeCounter();
console.log(other()); // node output:
// 1 2 3
// 1 The variable count lives inside makeCounter, which returned long before the first call to next. The returned function kept it alive, and kept it private: nothing outside can read or reset count except through the function. Because the link is live rather than a copy, each call sees the value the previous call left behind.
The second half shows the other half of the rule. Each call to makeCounter creates a fresh count, so other got its own. Closures capture per creation, not per function definition.
This is also where the old var loop bug comes from, and why it disappeared:
const oldStyle = [];
for (var i = 0; i < 3; i++) oldStyle.push(() => i);
console.log(oldStyle.map((fn) => fn()));
const newStyle = [];
for (let j = 0; j < 3; j++) newStyle.push(() => j);
console.log(newStyle.map((fn) => fn())); // node output:
// [ 3, 3, 3 ]
// [ 0, 1, 2 ] A var gets one binding for the whole enclosing function, or for the whole script or module when there is no function, as here. All three closures shared that one i and read its final value.
With let each iteration gets its own binding, so each closure captured a different one. That difference is the single strongest argument for never writing var in new code.
Higher-Order Functions
A higher-order function is one that takes a function, returns a function, or both. You already used one: map takes a function. The returning kind is how you build behaviour that wraps other behaviour:
function withLogging(fn) {
return (...args) => {
const result = fn(...args);
console.log("called with", args, "got", result);
return result;
};
}
const loudDouble = withLogging((n) => n * 2);
loudDouble(7); // node output:
// called with [ 7 ] got 14 The wrapper closes over fn, accepts any arguments through a rest parameter, and passes them straight through with spread. It forwards the arguments and the return value, and nothing else: being an arrow, it drops the receiver, cannot be called with new, and does not carry the original's own properties.
That pattern is everywhere once you recognise it. Debounce wrappers, retry wrappers, permission checks, and React hooks that return handlers are all the same idea: a function built at runtime that remembers its configuration through a closure.
Pitfalls and Debugging
TypeError: Cannot read properties of undefined (reading '...') inside a method. The method was detached from its object, almost always by passing it as a bare callback. Look for the point where the function was handed over without its dot, and wrap it in an arrow function that calls it properly.
Calling the callback instead of passing it. setTimeout(save(), 1000) runs save immediately and then hands its return value over as the handler. Node rejects that with TypeError: The "callback" argument must be of type function, while a browser accepts the undefined silently, so the only symptom there is save running too early. The fix is setTimeout(save, 1000), or setTimeout(() => save(draft), 1000) when arguments are involved.
The stale closure. Worth being precise about, because the usual explanation is backwards. A closure holds the binding itself rather than a copy of its value, so it does see later assignments to that same binding, which is exactly why the counter above keeps counting.
Staleness comes from a second binding, not from a frozen value. Each React render, or each call to a factory like the counter, creates fresh bindings, so a handler registered during an earlier render keeps reading that earlier render's variables. Copying a value into another variable does the same thing on a smaller scale.
Losing the return value of an arrow function. (n) => n * 2 returns the result, and (n) => { n * 2 } returns undefined, because braces start a body rather than an expression. Returning an object literal needs parentheses: (id) => ({ id }).
Assuming the arrow form fixes every this problem. Written as an object method or a prototype method, an arrow function permanently borrows the wrong scope and the failure is silent rather than loud. Use a normal method there, and save arrows for the callbacks inside it.
Frequently Asked Questions
Should you use arrow functions everywhere?
Use one whenever the surrounding this is the value you want, or when this is irrelevant, which covers most callbacks. Use a normal method when the receiver at the call site matters. Some callback APIs deliberately supply a this, and a class-field arrow is a legitimate instance-bound method.
Why is this undefined inside my method?
The method was probably detached from its object, usually by passing it directly as a callback. A normal function decides this at call time from what appears before the dot, and there is no dot left. Wrap the call in an arrow function, or use bind.
Are closures a memory leak?
Not by themselves. A closure keeps its captured variables alive only while the function itself is reachable, and both become eligible for collection once nothing refers to them, though the engine chooses when. Leaks come from holding the function forever, such as an event listener that is never removed.
What's the difference between a callback and a closure?
A callback is a role: a function passed to something else for that code to call, sometimes immediately as with map, sometimes much later as with a timer. A closure is a capability: a function that can still reach the variables where it was defined. Most callbacks are also closures.
Related
- JavaScript Language Basics for the full topic overview
- Values, Variables, and Equality for the
letandconstrules closures depend on - Objects, Arrays, and Destructuring for the array methods that take callbacks
- JavaScript for the language guide and the wider learning path
Sources
-
[1]
Functions(developer.mozilla.org)
-
[2]
Arrow Function Expressions(developer.mozilla.org)
-
[3]
Closures(developer.mozilla.org)
Read Next
Object literals and property access, the array methods worth knowing, destructuring, spread and rest, when Map and Set beat a plain object, and copying without mutating.
let and const, the primitive types, type coercion, == against ===, truthiness, the difference between null and undefined, and the ?? and ?. operators.
The four pieces of JavaScript every other page assumes: values and equality, functions and closures, objects and arrays, and modules that split code across files.