DEV_NET_CORE
GET_STARTED
ReactJavaScript fundamentals

Closures and lexical scope

Closures and lexical scope

Overview

Closures and lexical scope are core JavaScript concepts that explain how functions access variables, how state can be preserved between function calls, and why React Hooks can sometimes use outdated values when dependency arrays are incorrect.

Lexical scope means that variable access is determined by where code is written in the source code, not by where a function is called from at runtime. A function can access variables declared in its own scope and in outer scopes where the function was created.

A closure is created when a function keeps access to variables from its surrounding lexical scope, even after the outer function has finished executing. In JavaScript, functions are closures because they are created together with references to the lexical environment around them.

Simple example:

Code
function createCounter() {
  let count = 0;

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

const counter = createCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

The increment function still has access to count even after createCounter has returned. That preserved access is a closure.

Closures are used everywhere in JavaScript and React:

  • Event handlers.
  • Callback functions.
  • Timers.
  • Promises.
  • Async functions.
  • Module-level private state.
  • Factory functions.
  • Memoized functions.
  • React component event handlers.
  • useEffect, useCallback, and useMemo.
  • Custom Hooks.
  • Function components.

This topic is important for interviews because closures are one of the main reasons JavaScript behaves differently from many beginner expectations. Interviewers commonly use closure questions to test whether a candidate understands scope, variable lifetime, callback behavior, asynchronous code, loops, and React Hooks.

A strong answer should explain:

  • What lexical scope means.
  • What closure means.
  • How closures preserve access to variables.
  • How closures differ from global variables.
  • How closures can emulate private state.
  • Why var in loops can cause unexpected closure bugs.
  • How let and const fix block-scope loop issues.
  • Why stale closures happen in React.
  • How dependency arrays, refs, and functional state updates help avoid bugs.
  • How closures can retain memory when references are kept alive.

Closures are powerful, but they also require care. They can make code elegant and expressive, but they can also cause stale values, hidden state, memory retention, and confusing async behavior when misunderstood.

Core Concepts

Lexical Scope

Lexical scope means that the scope of a variable is based on where it is declared in the code.

Example:

Code
const globalMessage = "global";

function outer() {
  const outerMessage = "outer";

  function inner() {
    const innerMessage = "inner";

    console.log(innerMessage);
    console.log(outerMessage);
    console.log(globalMessage);
  }

  inner();
}

outer();

inner can access:

  • Its own variable: innerMessage.
  • The outer function variable: outerMessage.
  • The global variable: globalMessage.

This is possible because inner is written inside outer, and outer is written in the global scope.

The lookup direction is from inner to outer:

Code
inner scope -> outer scope -> global scope

An outer scope cannot access variables declared inside an inner scope:

Code
function outer() {
  function inner() {
    const secret = "hidden";
  }

  console.log(secret); // ReferenceError
}

Lexical scope is also called static scope because it can be determined by reading the source code structure.

Scope Chain

The scope chain is the chain of lexical environments that JavaScript searches when resolving a variable name.

Example:

Code
const appName = "Interview Prep";

function createLogger(prefix) {
  return function log(message) {
    console.log(`[${appName}] ${prefix}: ${message}`);
  };
}

const errorLogger = createLogger("ERROR");
errorLogger("Something failed");

When log executes, JavaScript resolves variables like this:

Code
message -> found in log's local scope
prefix -> found in createLogger's scope
appName -> found in global/module scope
console -> found in global environment

This chain exists because of lexical nesting.

Important interview point:

Code
The scope chain is determined when the function is created, not when it is called.

Function Scope

Variables declared with var are function-scoped. This means they are scoped to the nearest function, not the nearest block.

Code
function example() {
  if (true) {
    var message = "hello";
  }

  console.log(message); // "hello"
}

Even though message is declared inside the if block, it is available throughout the function.

This is different from let and const.

Block Scope

Variables declared with let and const are block-scoped. A block is usually code between { and }.

Code
function example() {
  if (true) {
    const message = "hello";
    console.log(message); // "hello"
  }

  console.log(message); // ReferenceError
}

Block scope helps prevent accidental variable reuse and fixes many classic closure bugs involving loops.

Best practice:

Code
Use const by default.
Use let when reassignment is needed.
Avoid var in modern JavaScript.

Closures

A closure is a function that remembers and can access variables from its lexical scope, even when that function executes outside the scope where it was created.

Example:

Code
function makeGreeting(name) {
  return function greet() {
    return `Hello, ${name}`;
  };
}

const greetMinh = makeGreeting("Minh");

console.log(greetMinh()); // "Hello, Minh"

makeGreeting has already finished executing, but greetMinh still remembers name.

This happens because the returned function has a reference to the lexical environment where it was created.

A practical definition:

Code
Closure = function + access to its surrounding lexical environment.

Closures Are Created at Function Creation Time

A closure is created when the function is created, not when it is called.

Code
function outer(value) {
  return function inner() {
    console.log(value);
  };
}

const first = outer("first");
const second = outer("second");

first();  // "first"
second(); // "second"

Each call to outer creates a new lexical environment. Each returned inner function closes over a different value.

This is why two closures created from the same function can preserve different state.

Closures Preserve Variables, Not Just Values

Closures capture variable bindings, not only a one-time copy of primitive values.

Code
function createCounter() {
  let count = 0;

  return {
    increment() {
      count += 1;
    },
    getCount() {
      return count;
    }
  };
}

const counter = createCounter();

counter.increment();
counter.increment();

console.log(counter.getCount()); // 2

Both increment and getCount close over the same count binding. When increment changes count, getCount sees the updated value.

This is important:

Code
Closures preserve access to variables, not just snapshots of values in every situation.

However, React renders introduce a special practical case: each render creates new variables, so a callback created during an older render may close over that older render's values. This is the source of many stale closure bugs.

Private State with Closures

Closures can emulate private state because the enclosed variable is not directly accessible from outside.

Code
function createBankAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      if (amount <= 0) {
        throw new Error("Amount must be positive");
      }

      balance += amount;
    },
    withdraw(amount) {
      if (amount > balance) {
        throw new Error("Insufficient funds");
      }

      balance -= amount;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50);

console.log(account.getBalance()); // 150
console.log(account.balance); // undefined

The balance variable is private to the closure. Consumers can only interact with it through the returned methods.

Real-world uses:

  • Module-private variables.
  • Factory functions.
  • Encapsulated counters.
  • Function-level caches.
  • Controlled state access.
  • Avoiding global mutable state.

Closures and Callback Functions

Callbacks often use closures because they need access to variables from an outer function.

Code
function setupButton(button, userId) {
  button.addEventListener("click", function handleClick() {
    console.log(`Clicked by user ${userId}`);
  });
}

handleClick closes over userId. When the button is clicked later, the callback still has access to the user ID.

This is useful, but it also means callbacks can keep variables alive in memory as long as the callback is registered.

Best practice:

Code
Remove event listeners when they are no longer needed.
Avoid closing over large objects unnecessarily.

Closures and Timers

Timers are a common closure example.

Code
function delayedLog(message) {
  setTimeout(() => {
    console.log(message);
  }, 1000);
}

delayedLog("Hello later");

The callback runs later, after delayedLog has returned. It still has access to message because of closure.

Common mistake with changing outer variables:

Code
let message = "first";

setTimeout(() => {
  console.log(message);
}, 1000);

message = "second";

This logs:

Code
second

The closure references the variable binding. By the time the timer runs, the binding contains the new value.

Closures and Loops with var

A classic closure bug happens when var is used in loops.

Code
for (var i = 0; i < 3; i += 1) {
  setTimeout(() => {
    console.log(i);
  }, 100);
}

Output:

Code
3
3
3

Why? var is function-scoped, so all callbacks close over the same i. By the time the callbacks run, the loop has finished and i is 3.

Closures and Loops with let

Using let creates a new binding for each loop iteration.

Code
for (let i = 0; i < 3; i += 1) {
  setTimeout(() => {
    console.log(i);
  }, 100);
}

Output:

Code
0
1
2

Each callback closes over a different i binding for that loop iteration.

This is a common interview question because it tests understanding of both closures and block scope.

IIFE and Older Closure Patterns

Before let and const, developers often used an Immediately Invoked Function Expression, or IIFE, to create a new scope.

Code
for (var i = 0; i < 3; i += 1) {
  (function (index) {
    setTimeout(() => {
      console.log(index);
    }, 100);
  })(i);
}

Output:

Code
0
1
2

The IIFE creates a new function scope for each iteration, and each callback closes over a separate index parameter.

Modern JavaScript usually uses let instead.

Closures and Modules

JavaScript modules create their own scope. Variables declared in a module are not global by default.

Code
let token = null;

export function setToken(value) {
  token = value;
}

export function getToken() {
  return token;
}

token is module-private. Other modules cannot directly access it unless it is exported.

This is a modern alternative to older closure-based module patterns.

Older module pattern:

Code
const authStore = (function () {
  let token = null;

  return {
    setToken(value) {
      token = value;
    },
    getToken() {
      return token;
    }
  };
})();

This uses closure to create private state.

Closures and Higher-Order Functions

A higher-order function is a function that receives another function, returns another function, or both.

Closures are often used with higher-order functions.

Code
function multiplyBy(factor) {
  return function (value) {
    return value * factor;
  };
}

const double = multiplyBy(2);
const triple = multiplyBy(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

double closes over factor = 2, while triple closes over factor = 3.

Real-world examples:

  • Event handler factories.
  • Middleware factories.
  • Validator factories.
  • Function composition.
  • Currying.
  • Memoization.
  • React custom Hooks.

Closures and Currying

Currying means transforming a function with multiple arguments into a sequence of functions that each take one or fewer arguments.

Code
function createUrlBuilder(baseUrl) {
  return function buildUrl(path) {
    return `${baseUrl}${path}`;
  };
}

const buildApiUrl = createUrlBuilder("https://api.example.com");

console.log(buildApiUrl("/users"));

The returned function remembers baseUrl through closure.

This is useful for preconfiguring functions.

Closures and Memoization

Memoization stores the result of expensive calculations so repeated calls can reuse the cached result.

Code
function memoize(fn) {
  const cache = new Map();

  return function memoized(arg) {
    if (cache.has(arg)) {
      return cache.get(arg);
    }

    const result = fn(arg);
    cache.set(arg, result);
    return result;
  };
}

const square = memoize((value) => {
  console.log("calculating");
  return value * value;
});

console.log(square(4)); // calculating, 16
console.log(square(4)); // 16

The returned memoized function closes over cache and fn.

Trade-offs:

  • Faster repeated calls.
  • More memory usage.
  • Cache invalidation may be needed.
  • Cache keys must be designed carefully.
  • Long-lived closures can retain cache data for a long time.

Closures and Memory Retention

Closures can keep variables alive as long as the closure itself is reachable.

Code
function createLargeClosure() {
  const largeData = new Array(1_000_000).fill("data");

  return function readFirstItem() {
    return largeData[0];
  };
}

const reader = createLargeClosure();

largeData cannot be garbage collected while reader is still reachable because reader closes over it.

This is not a memory leak by itself, but it can become one if long-lived closures keep unnecessary large data alive.

Common sources of memory retention:

  • Event listeners not removed.
  • Timers not cleared.
  • Long-lived caches.
  • Global arrays storing callbacks.
  • Closures over large objects.
  • React effects without cleanup.

Best practices:

  • Remove event listeners in cleanup.
  • Clear intervals and timeouts when needed.
  • Avoid closing over large objects unnecessarily.
  • Use bounded caches.
  • In React, return cleanup functions from effects.

Lexical Scope vs Dynamic Scope

JavaScript uses lexical scope, not dynamic scope.

Lexical scope means variable lookup is based on where a function is written.

Dynamic scope would mean variable lookup is based on where a function is called from. JavaScript does not work this way for normal variables.

Example:

Code
const message = "global";

function printMessage() {
  console.log(message);
}

function run() {
  const message = "local";
  printMessage();
}

run(); // "global"

printMessage was defined in the global scope, so it uses the global message, even though it is called from inside run.

This is a strong example for explaining lexical scope in interviews.

Scope vs this

Lexical scope and this are different concepts.

Lexical scope controls variable lookup.

this is a special value determined by how a function is called, except for arrow functions, which capture this lexically from the surrounding scope.

Example:

Code
const user = {
  name: "Minh",
  regularFunction() {
    console.log(this.name);
  },
  arrowFunction: () => {
    console.log(this.name);
  }
};

user.regularFunction(); // "Minh"
user.arrowFunction();   // usually undefined in modules

Arrow functions do not have their own this; they close over this from the surrounding lexical context.

Important distinction:

Code
Closures are about variable scope.
this is about call context, except arrow functions capture this lexically.

Closures and Asynchronous Code

Asynchronous callbacks often use closures.

Code
function fetchUser(userId) {
  return fetch(`/api/users/${userId}`)
    .then((response) => response.json())
    .then((user) => {
      console.log(`Loaded user ${userId}:`, user.name);
    });
}

The final callback closes over userId.

Async/await also uses closures when inner functions access outer variables:

Code
async function loadUser(userId) {
  const response = await fetch(`/api/users/${userId}`);
  const user = await response.json();

  return function printUser() {
    console.log(user.name);
  };
}

The returned printUser function closes over user.

Common asynchronous closure bug:

Code
function runSearch(query) {
  setTimeout(() => {
    console.log(`Searching for ${query}`);
  }, 500);
}

runSearch("react");
runSearch("javascript");

Each call has its own query binding, so this works correctly. Problems usually happen when a shared outer variable is mutated between async callbacks.

Stale Closures

A stale closure happens when a function closes over a value from an earlier time, but later code expects it to use the newest value.

Plain JavaScript example:

Code
function createLogger() {
  let count = 0;

  const message = `Count is ${count}`;

  return {
    increment() {
      count += 1;
    },
    log() {
      console.log(message);
    }
  };
}

const logger = createLogger();
logger.increment();
logger.increment();
logger.log(); // "Count is 0"

message was calculated once when count was 0. The log function closes over that message, not a dynamically recalculated message.

Fix:

Code
function createLogger() {
  let count = 0;

  return {
    increment() {
      count += 1;
    },
    log() {
      console.log(`Count is ${count}`);
    }
  };
}

Now log reads the current count binding when it runs.

Closures in React Function Components

React function components are functions. Every render calls the component again and creates a new set of local variables, props references, state values, event handlers, and closures.

Example:

Code
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    console.log(count);
    setCount(count + 1);
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

Each render creates a new handleClick function that closes over that render's count value.

Usually this is exactly what you want. The handler rendered to the screen corresponds to the values from that render.

Closures become tricky when callbacks are stored and executed later, such as:

  • setInterval.
  • setTimeout.
  • Event listeners.
  • Subscriptions.
  • WebSocket callbacks.
  • Promise callbacks.
  • Effects with incomplete dependencies.
  • Memoized callbacks with incorrect dependencies.

Stale Closures in useEffect

A common React stale closure bug happens when an effect reads a value but the dependency array does not include it.

Bad:

Code
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    async function load() {
      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();
      setResults(data);
    }

    load();
  }, []);

  return <ResultList results={results} />;
}

The effect closes over the initial query. If query changes, the effect does not rerun, so the component can show stale results.

Better:

Code
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    async function load() {
      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();
      setResults(data);
    }

    load();
  }, [query]);

  return <ResultList results={results} />;
}

Now the effect reruns when query changes.

Interview point:

Code
React dependency arrays are closely related to closures. If an effect or memoized callback uses a reactive value, it usually belongs in the dependency array.

Stale Closures in setInterval

Bad:

Code
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
}

The interval callback closes over the initial count, so it repeatedly calls setCount(0 + 1).

Fix with functional state update:

Code
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount((current) => current + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
}

The functional update receives the latest state value, so the interval does not need to close over count.

useRef and Current Values

Sometimes a callback should not be recreated on every value change, but it still needs access to the latest value. A ref can store the latest value without causing rerenders.

Code
function WindowLogger({ value }: { value: string }) {
  const latestValueRef = useRef(value);

  useEffect(() => {
    latestValueRef.current = value;
  }, [value]);

  useEffect(() => {
    function handleResize() {
      console.log(latestValueRef.current);
    }

    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return null;
}

The resize listener is registered once, but it reads the latest value from the ref.

Use refs carefully. A ref can avoid stale closures, but it can also hide reactive dependencies if overused.

useCallback and Closures

useCallback returns a memoized function, but the function still closes over values from the render when it was created.

Bad:

Code
const handleSave = useCallback(() => {
  saveUser(userId, formData);
}, [userId]);

If formData changes, handleSave still uses the old formData because it is missing from the dependency array.

Better:

Code
const handleSave = useCallback(() => {
  saveUser(userId, formData);
}, [userId, formData]);

Important:

Code
useCallback does not prevent closures. It memoizes a closure based on dependencies.

useMemo and Closures

useMemo also depends on closures and dependencies.

Bad:

Code
const filteredItems = useMemo(() => {
  return items.filter((item) => item.name.includes(searchText));
}, [items]);

If searchText changes but is not included in dependencies, the memoized result can be stale.

Better:

Code
const filteredItems = useMemo(() => {
  return items.filter((item) => item.name.includes(searchText));
}, [items, searchText]);

Use useMemo for expensive derived values, not as a default for every calculation.

Functional Updates in React

Functional state updates help avoid stale closures when the next state depends on the previous state.

Bad:

Code
setCount(count + 1);
setCount(count + 1);

This may only increment once because both calls use the same closed-over count value.

Better:

Code
setCount((current) => current + 1);
setCount((current) => current + 1);

This correctly applies two increments.

Use functional updates when:

  • New state depends on previous state.
  • The update happens inside timers.
  • The update happens inside async callbacks.
  • Multiple updates may be queued.
  • You want to avoid adding state as a callback dependency only for computing next state.

React Dependency Arrays

Dependency arrays tell React when to recreate or rerun effect, memo, or callback logic.

Examples:

Code
useEffect(() => {
  document.title = title;
}, [title]);
Code
const handleSubmit = useCallback(() => {
  submitForm(formData);
}, [formData]);
Code
const visibleItems = useMemo(() => {
  return items.filter((item) => item.visible);
}, [items]);

General rule:

Code
If a reactive value from props, state, or component scope is used inside the hook callback, it should be included in the dependency array unless there is a deliberate alternative pattern.

Common alternatives:

  • Move code inside the effect.
  • Use functional state updates.
  • Use refs for latest values.
  • Move stable functions outside the component.
  • Use reducers for complex state transitions.
  • Split effects by responsibility.

Avoid disabling dependency lint rules without understanding the closure behavior.

Closures in Custom Hooks

Custom Hooks often return functions that close over Hook state.

Code
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => {
    setValue((current) => !current);
  }, []);

  return { value, toggle };
}

toggle uses a functional state update, so it does not need to depend on value.

A bad custom Hook can expose stale closures if dependencies are incorrect:

Code
function useSearch(query: string) {
  const [results, setResults] = useState<string[]>([]);

  const reload = useCallback(async () => {
    const response = await fetch(`/api/search?q=${query}`);
    setResults(await response.json());
  }, []);

  return { results, reload };
}

Fix:

Code
function useSearch(query: string) {
  const [results, setResults] = useState<string[]>([]);

  const reload = useCallback(async () => {
    const response = await fetch(`/api/search?q=${query}`);
    setResults(await response.json());
  }, [query]);

  return { results, reload };
}

Closures and Event Handler Factories in React

Closures are useful for creating event handlers with parameters.

Code
function TodoList({ todos, onToggle }: TodoListProps) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <button onClick={() => onToggle(todo.id)}>
            {todo.title}
          </button>
        </li>
      ))}
    </ul>
  );
}

The arrow function closes over todo.id.

This is normal and often fine. Avoid premature optimization. Only optimize handler creation if there is evidence of performance problems or unnecessary rerenders in memoized child components.

Closures vs Classes

Closures can provide private state without classes.

Closure-based counter:

Code
function createCounter() {
  let count = 0;

  return {
    increment() {
      count += 1;
    },
    getCount() {
      return count;
    }
  };
}

Class-based counter:

Code
class Counter {
  #count = 0;

  increment() {
    this.#count += 1;
  }

  getCount() {
    return this.#count;
  }
}

Both can model private state. Closures are function-based. Classes are object/prototype-based and can use private fields.

Use closures when:

  • You want a small factory function.
  • You want to preserve local variables.
  • You are writing callbacks or higher-order functions.
  • You want simple private state.

Use classes when:

  • You need many instances with shared prototype methods.
  • You want class syntax and private fields.
  • You model objects with identity and behavior.
  • Your team prefers class-based patterns.

Common Mistakes

Common closure and lexical scope mistakes include:

  • Thinking scope is based on where a function is called instead of where it is defined.
  • Using var in loops and expecting each callback to get a separate value.
  • Forgetting that closures can keep variables alive in memory.
  • Assuming closures always capture a frozen snapshot.
  • Mutating closed-over variables in hard-to-track ways.
  • Creating stale closures in React effects, callbacks, and memos.
  • Omitting dependencies from React dependency arrays.
  • Disabling exhaustive-deps without understanding the effect.
  • Using refs to avoid dependencies when the effect should actually resynchronize.
  • Keeping event listeners or intervals alive without cleanup.
  • Closing over large objects unnecessarily.
  • Confusing lexical scope with this binding.
  • Overusing closures when a simple parameter or local variable is clearer.

Best Practices

Use const by default and let when reassignment is needed.

Avoid var in modern JavaScript.

Keep closures small and focused.

Avoid hidden mutation of closed-over variables when it makes behavior hard to reason about.

Use closures for callbacks, private state, factories, and higher-order functions when they improve clarity.

Clean up event listeners, subscriptions, and intervals.

Be careful when closures retain large objects or long-lived caches.

In React, include all reactive dependencies in useEffect, useCallback, and useMemo dependency arrays.

Use functional state updates when the next state depends on the previous state.

Use refs when a stable callback must read the latest value without resubscribing or rerendering.

Split effects by responsibility instead of forcing one effect to handle unrelated logic.

Do not disable Hook dependency lint rules unless you can clearly explain why the closure behavior is correct.

When debugging closure issues, ask:

Code
Where was this function created?
Which variables did it close over?
Can those variables change?
Is this callback running later than expected?
In React, which render created this callback?
Are the hook dependencies complete?

Interview Practice

PreviousTemporal tables, historical retention, and lifecycle managementNext UpModules and import/export behavior