Functions, Callbacks, and Closures in JavaScript

Published Updated

A JavaScript function is reusable code that you can call, pass to another function or return as a value. Picture it 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 builds on JavaScript language basics, especially variables and scope.

How Do Function Declarations and Expressions Differ?

A function declaration defines a named function that is available throughout its scope; a function expression creates a function when that expression runs. 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 reason teams reach for arrow functions is in the next section.

Arrow syntax has two forms, and reading other people's code means recognizing 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.

How Do Arrow Functions Handle 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 undefined for a plain call in strict mode. A non-strict plain call uses the global object. 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 method captures that call's this value. An instance class-field arrow captures the instance; a static class-field arrow captures the class.

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).

How Do Default and Rest Parameters Work?

A JavaScript default parameter supplies a value when its argument is missing or explicitly undefined. A rest parameter collects the remaining arguments 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.

What Is a JavaScript Callback?

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.

Callbacks also appear in array methods, event listeners, timers and promise handlers. 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 generalizes 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.

What Is a JavaScript Closure?

A JavaScript closure combines a function with access to its surrounding variables. Those are live bindings, not copies 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.

What Is a Higher-Order Function?

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 behavior that wraps other behavior:

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 recognize 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. 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. An arrow stored as an object or prototype property still uses its surrounding this value, which may be different from the object you call it on. Use a normal method there, and save arrows for the callbacks inside it.

Frequently Asked Questions

Should you use JavaScript arrow functions everywhere?

Use JavaScript arrow functions when you want the surrounding this value or do not need this at all. Use a regular method when this should depend on the object making the call. Some callback APIs supply their own this value, which an arrow ignores.

Why is this undefined inside a JavaScript method?

A JavaScript method can receive undefined as this when it is detached from its object and called as a plain function in strict mode. Keep the object in the call with () => object.method(), or bind the method to the object. Arrow functions instead keep the this value from their surrounding scope.

Do JavaScript closures cause memory leaks?

JavaScript closures do not inherently cause memory leaks. A reachable function can keep captured data alive, even after the outer function returns. Remove listeners or other references when they are no longer needed so unused functions and data can be collected.

What is the difference between a JavaScript callback and a closure?

A JavaScript callback is a function passed to other code to call, either synchronously or later. A closure is a function together with access to its surrounding variables. The same function can be both; passing it as a callback does not create a new surrounding scope.

Sources

  1. [1]
    Functions
    (developer.mozilla.org)
  2. [2]
    Arrow Function Expressions
    (developer.mozilla.org)
  3. [3]
    Closures
    (developer.mozilla.org)