FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·050·11 MIN READ

050: Functions II: Scope, Closures, Recursion, and the Call Stack

TOPICS COVERED: Functions II: Scope, Closures, Recursion, and the Call Stack

Outcomes

By the end of this lesson, you can:

  • create function expressions and arrow function expressions;
  • choose readable syntax based on context rather than novelty;
  • explain global/module, function, and block scope;
  • identify local variables and shadowing;
  • pass a function as a callback to an array method; and
  • refactor prior functions without changing behavior.

Prerequisites and Retrieval

Start by retrieving the function declarations from 049.

  1. What is the difference between a parameter and an argument?
  2. Why should a calculator return rather than only log?
  3. What does a guard return accomplish?

This lesson adds more ways to create function values. It does not replace function declarations, and it deliberately keeps closure theory at an introductory level before returning to it in the advanced sections.

Terms

  • function expression: A function defined inside an expression, often stored in a variable. — Source: MDN: function expression
  • arrow function: Compact => syntax without its own this, arguments, or prototype. — Source: MDN: Arrow functions
  • anonymous function: A function without a name, used inline as a value. — Source: MDN: function expression
  • first-class value: A function is a value that can be assigned, passed, and returned like other data. — Source: MDN: First-class functions
  • callback: “A function passed into another function as an argument, which is then invoked inside.” — Source: MDN: Callback function
  • local variable: A binding visible only within its function or block scope. — Source: MDN: Glossary — Scope
  • global scope: Bindings accessible everywhere in the script or module graph. — Source: MDN: Scope
  • module scope: The top-level bindings of a module, private unless exported. — Source: MDN: JavaScript modules
  • lexical scope: Scoping determined by where code is written, enabling closures. — Source: MDN: Closures
  • implicit return: An arrow function body without braces returns its expression automatically. — Source: MDN: Arrow functions
  • Closure (official): "A closure is the combination of a function bundled together with references to its surrounding state (the lexical environment)." — Source: MDN: Closure
  • Callback (official): "A callback function is a function passed into another function as an argument, which is then invoked inside the outer function." — Source: MDN: Callback function
  • Lexical scope (official): "Lexical scope is the scope defined by the position of declarations in source code." — Source: MDN: Scope

Beginner Explanation and Mental Model

In 049, a function declaration gave reusable code a name:

js
function double(number) {
  return number * 2;
}

That named function is also a value. A function expression creates a function value inside an expression and assigns it to a constant:

js
const double = function (number) {
  return number * 2;
};

An arrow function is another expression form:

js
const double = (number) => {
  return number * 2;
};

When the body is one expression, the braces and return can be omitted:

js
const double = (number) => number * 2;

All of these examples produce the same result for the same input, but they do not have identical language behavior. Function declarations are a good fit for prominent named domain operations, and they can be called earlier in their scope because declarations are hoisted. A function stored in a const cannot be accessed until that declaration has been initialized. Arrow functions also do not have their own this or arguments, which becomes significant for object methods and other advanced patterns. The practical rule is not “convert everything to arrows”; choose the form that matches the context.

Use parentheses around parameters consistently in course code, even though an arrow function with one parameter may omit them. The explicit form is easier to extend when the function later gains another parameter.

The syntax choice is separate from the function's job. First preserve what the function receives and returns; then choose the clearest spelling for that job.

Scope

Scope answers a basic debugging question: where can a name be read? A function creates a local scope:

js
const taxRate = 0.10;

function calculateTax(price) {
  const tax = price * taxRate;
  return tax;
}

console.log(calculateTax(500)); // 50
// console.log(tax); // ReferenceError

Inside calculateTax, the code can read the parameter price, the local tax, and the outer taxRate. Code outside the function cannot read the local tax. In production code, prefer passing important dependencies as parameters, such as calculateTax(price, taxRate), because explicit inputs make a function easier to test and reason about.

const and let also have block scope. If an inner declaration repeats a name from an outer scope, the inner binding shadows the outer one. Shadowing is valid JavaScript, but it makes readers stop and determine which value a name refers to. Distinct, descriptive names are usually clearer.

When a value unexpectedly appears to change, inspect the scope of each declaration before blaming the function call. The name being visible does not guarantee that it refers to the outer binding you had in mind.

Callbacks

Because functions are values, one function can receive another function as input. The receiving function decides when to invoke it and which arguments to provide. For example, Array.prototype.forEach() invokes its callback once for each existing element in the array:

js
const names = ["Asha", "Ravi"];
names.forEach((name) => {
  console.log(name);
});

The arrow function is not invoked by the source line that defines it. That line passes the function to forEach; forEach invokes it for each element. More array methods for transforming data arrive in 053. For now, the key idea is that the callback is a value supplied to an operation that controls the calls.

This distinction between passing a function and calling a function is one of the most common sources of callback bugs. Read the API documentation to confirm both when the callback runs and which arguments it receives.

Worked Example: Refactor a Calculator

Start with a declaration, then write equivalent operations as a function expression and arrow functions:

js
function addDeclaration(a, b) {
  return a + b;
}

const subtractExpression = function (a, b) {
  return a - b;
};

const multiplyArrow = (a, b) => a * b;

const divideArrow = (a, b) => {
  if (b === 0) {
    return "Cannot divide by zero";
  }
  return a / b;
};

console.log(addDeclaration(8, 2));
console.log(subtractExpression(8, 2));
console.log(multiplyArrow(8, 2));
console.log(divideArrow(8, 2));
console.log(divideArrow(8, 0));

Expected output:

text
10
6
16
4
Cannot divide by zero

The multiplication function uses an implicit return because its entire body is one expression. Division needs a guard and more than one statement, so braces and explicit return statements make the control flow clear. A common mistake is (a, b) => { a * b; }. Once braces are present, they create a block body; without return, that function returns undefined.

The refactor is successful only if the observable behavior stays the same, including the divide-by-zero case. A shorter function is not automatically a better function.

Intermediate Example: Scope and Callbacks

js
const courseName = "JavaScript";

const formatScore = (userName, score) => {
  const passed = score >= 60;
  const resultLabel = passed ? "passed" : "needs practice";
  return `${userName}: ${score} (${resultLabel})`;
};

const scores = [72, 48, 91];

scores.forEach((score, index) => {
  const displayNumber = index + 1;
  const message = formatScore(`User ${displayNumber}`, score);
  console.log(courseName, message);
});

Expected output:

text
JavaScript User 1: 72 (passed)
JavaScript User 2: 48 (needs practice)
JavaScript User 3: 91 (passed)

formatScore has local bindings named passed and resultLabel. The callback passed to forEach has its own parameter bindings, plus local displayNumber and message. Both functions can read the outer courseName. The outer scope cannot read those locals once the relevant calls have finished. forEach supplies the current element and its index to the callback; it also supplies the array as a third argument.

A callback can have a name when reuse or a clearer stack trace is useful:

js
const logScore = (score) => {
  console.log("Score:", score);
};

scores.forEach(logScore);

Pass logScore, not logScore(). The parentheses call the function immediately, and the result of that call, here undefined, is what would be passed to forEach.

That same distinction applies outside arrays: event systems, timers, and many application APIs expect a function to call later rather than the result of a call made during setup.

Optional Advanced Extension: Lexical Capture

An inner function can read bindings from the lexical scope in which it was defined:

js
const prefix = "Result";

const printValue = (value) => {
  console.log(prefix, value);
};

[10, 20].forEach(printValue);

This ability to access an outer lexical binding is the foundation of closures. No deeper theory is required for this first example. Keep important dependencies explicit in parameters when they define the function's meaning. Reading outer configuration is reasonable when that configuration is genuinely shared, but it should be an intentional choice rather than an accidental hidden dependency.

Interview focus: closures, this, and explicit binding

A closure keeps access to lexical bindings after the outer function has returned. That is why closures are useful for private state, factories, memoization, and event handlers. The captured binding is not automatically a frozen snapshot; when the closure runs, it reads the binding's current value.

js
function makeCounter() {
  let count = 0;
  return () => ++count;
}

const nextCount = makeCounter();
console.log(nextCount(), nextCount()); // 1 2

An interview follow-up is the loop-closure trace. With let, each iteration gets its own binding. With var, callbacks share one binding:

js
const callbacks = [];
for (let index = 0; index < 3; index += 1) {
  callbacks.push(() => index);
}
console.log(callbacks.map((callback) => callback())); // [0, 1, 2]

In UI code, a stale closure is a callback that retained a value from an older render. The appropriate fix depends on the framework: follow its dependency model, pass the current value when scheduling the work, or intentionally read mutable state through a documented ref or store. Switching to var or hiding the dependency in a global does not solve the underlying problem.

For a regular function, this is determined by the call site:

js
const account = {
  name: "Maya",
  label() { return this.name; },
};

console.log(account.label()); // Maya
const detached = account.label;
console.log(detached());      // undefined in strict/module code

An arrow function has no own this; it captures this from its surrounding lexical scope. Use a regular method when the receiver is meant to supply the context. Use an arrow when the surrounding lexical context is the intended context.

call, apply, and bind let you make the context choice explicit. call receives individual arguments, apply receives an argument array, and bind returns a new function with a permanently selected context and, optionally, prefilled arguments.

js
function introduce(greeting, punctuation) {
  return `${greeting}, ${this.name}${punctuation}`;
}

console.log(introduce.call({ name: "Ravi" }, "Hi", "!"));
console.log(introduce.apply({ name: "Ravi" }, ["Hello", "."]));
const greetMaya = introduce.bind({ name: "Maya" }, "Welcome");
console.log(greetMaya("!"));

Useful follow-ups include: What happens if a bound function is used with call? Its bound this wins. What does an arrow do with call? It ignores the supplied this. Why bind a callback? Binding preserves a method's receiver when the method is passed to another API. Choose an arrow wrapper or bind deliberately, because either approach creates a new function identity.

Deep Dive: Lexical Environments, Closures, Recursion, and Stack Frames

A closure is not merely “a function inside a function.” It is a function together with access to the lexical environment where it was created.

js
function createCounter(start = 0) {
  let count = start;

  return function increment() {
    count += 1;
    return count;
  };
}

const next = createCounter(10);

console.log(next()); // 11
console.log(next()); // 12

The call to createCounter has completed, but count remains reachable through the returned function. That retained environment is what allows later calls to continue from the previous value.

If no live function or other reachable object can access the environment, it can eventually be reclaimed. A closure therefore preserves state, but it does not make that state immortal.

Useful closure: configuration

Closures are also useful for creating configured functions:

js
function createTaxCalculator(rate) {
  return (subtotal) => subtotal * rate;
}

const calculateGST = createTaxCalculator(0.18);
console.log(calculateGST(1000));

Here, calculateGST retains access to the rate supplied when the calculator was created.

Recursion

A recursive function calls itself. It needs two parts: a recursive step that moves toward completion and a base case that stops the calls.

js
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

For deeply nested structures or very large input, iteration may be safer because JavaScript engines have finite call stacks.

When tracing recursion, write down the argument at each call and identify the exact call that reaches the base case. If the argument never moves toward that case, the function can recurse until the engine reports a stack overflow.

Call stack reasoning

js
function first() {
  second();
}

function second() {
  third();
}

function third() {
  console.trace("stack");
}

first();

When debugging, read a stack trace from the error site outward. Each frame represents a call that is still part of the chain, so the trace helps reconstruct how execution reached the failing line.

Mistakes and Debugging

  • Calling an expression before declaration: Move the call below const functionName = ...; the function value is unavailable before that declaration is initialized.
  • Missing arrow return: With braces, write return. Without braces, provide exactly one expression to return.
  • Returning an object literal implicitly: (value) => ({ value }) needs parentheses so the braces are parsed as an object expression. Objects are covered later.
  • Invoking a callback too early: Pass handleValue, not handleValue().
  • Assuming callback parameters have chosen values: The array method supplies element, index, and array in a documented order.
  • Reading a local outside scope: Return the value or use it within the scope where it exists.
  • Shadowing outer names: Rename the inner binding so the name communicates which value it represents.
  • Using an arrow as an object method or constructor: Arrows lack their own this and cannot be constructors; use the appropriate regular function or method syntax later.
  • Refactoring syntax and behavior together: First preserve inputs, outputs, and edge cases. Then verify every call after changing the syntax.

Best Practices

  • Prefer declarations for prominent named domain functions; use arrow functions naturally for short callbacks and local expressions.
  • Store function expressions in const unless the function binding itself must change.
  • Use implicit returns only when the expression remains immediately readable.
  • Keep local variables in the smallest useful scope.
  • Pass important dependencies as parameters rather than reading mutable globals.
  • Name reusable callbacks; inline tiny one-use callbacks.
  • Return data from transformations and keep effects such as logging explicit.
  • Do not refactor to arrow syntax solely to reduce line count.

Tiered Exercises

Core

Rewrite the declaration functions square(number) and isAdult(age) as arrow functions. Call each one twice and verify that the outputs have not changed.

Practice

Create an array of three prices. Write a named logPrice callback that prints each price and its one-based position using forEach. Calculate tax in a separate arrow function and include the tax in the output.

Professional Extension

Create makeLabel(prefix). It should define and return an inner arrow function that accepts a value and returns a formatted string. Use the returned function twice, then explain which outer value the inner function reads.

Complete Solutions

js
const square = (number) => number * number;
const isAdult = (age) => age >= 18;

console.log(square(4));  // 16
console.log(square(7));  // 49
console.log(isAdult(18)); // true
console.log(isAdult(15)); // false
js
const prices = [100, 250, 80];
const calculateTax = (price) => price * 0.10;

const logPrice = (price, index) => {
  const position = index + 1;
  const tax = calculateTax(price);
  console.log(`Item ${position}: price ${price}, tax ${tax}`);
};

prices.forEach(logPrice);

Expected output:

text
Item 1: price 100, tax 10
Item 2: price 250, tax 25
Item 3: price 80, tax 8
js
const makeLabel = (prefix) => {
  const formatValue = (value) => `${prefix}: ${value}`;
  return formatValue;
};

const scoreLabel = makeLabel("Score");
console.log(scoreLabel(80));
console.log(scoreLabel(95));

The returned formatValue function reads the prefix parameter from the scope in which it was created. The output is Score: 80 and Score: 95.

Recap and Exit Questions

Functions are first-class values. Function expressions and arrow functions can be stored in constants and passed as callbacks. Scope determines which names code can read, and callbacks are invoked by the receiving operation with documented arguments.

  1. How does a function declaration differ from an expression assigned to const?
  2. When does an arrow need explicit return?
  3. What is a local variable?
  4. Why pass callback rather than callback()?
  5. What arguments does forEach supply to its callback?

Official References

References checked 2026-08-24.

Closures, lexical scope, and this

A closure is a function together with the lexical environment it can still access. Use one to preserve private state, then explain what is retained and when it can be released. Compare a method, a regular callback, and an arrow function.

A regular function this depends on how it is called; an arrow function captures this lexically and has no own arguments or prototype. Test call, apply, bind, detached methods, object methods, constructors, and event callbacks. Interview answers should explain call-site binding rather than claiming that arrow functions are always better.

Reader page: /javascript/lesson/050/functions-ii-scope-closures-recursion-and-the-call-stack