Published on

JavaScript Closures Explained - Once and For All

Authors

Introduction

Closures are one of JavaScript's most misunderstood concepts — and one of its most powerful. They're the mechanism behind React hooks, the module pattern, debouncing, memoization, and private state.

By the end of this guide, closures will feel completely natural.

What is a Closure?

A closure is a function that remembers the variables from its outer scope even after the outer function has returned.

function outer() {
  const message = 'Hello!'  // Variable in outer scope

  function inner() {
    console.log(message)  // inner "closes over" message
  }

  return inner
}

const greet = outer()  // outer() has returned
greet()                // Still prints "Hello!" — why?

inner still has access to message because it formed a closure over it. The variable lives as long as there's a reference to inner.

A Practical Example: Counter

function createCounter(start = 0) {
  let count = start  // Private state!

  return {
    increment: () => ++count,
    decrement: () => --count,
    reset: () => { count = start },
    getCount: () => count,
  }
}

const counter = createCounter(10)
console.log(counter.increment())  // 11
console.log(counter.increment())  // 12
console.log(counter.decrement())  // 11
console.log(counter.getCount())   // 11

// count is private — can't access it directly!
console.log(counter.count)  // undefined

count is private — only accessible through the returned methods. This is the Module Pattern!

The Classic Loop Problem

This trips up almost every JavaScript developer:

// ❌ Bug — all log '3'!
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100)
}
// 3, 3, 3 — NOT 0, 1, 2!
// Why? var is function-scoped, all closures share the SAME i

// ✅ Fix 1: Use let (block-scoped)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100)
}
// 0, 1, 2 ✅

// ✅ Fix 2: IIFE to capture value
for (var i = 0; i < 3; i++) {
  ((j) => {
    setTimeout(() => console.log(j), 100)
  })(i)
}
// 0, 1, 2 ✅

The let version creates a new binding for each iteration — each closure captures its own i.

Closures in Event Handlers

function setupButton(buttonName, message) {
  const button = document.getElementById(buttonName)

  // The handler closes over 'message' — different for each call
  button.addEventListener('click', () => {
    alert(message)
  })
}

setupButton('btn1', 'Hello!')
setupButton('btn2', 'Goodbye!')
// Each button remembers its own message

Closures for Private State — Module Pattern

const userService = (() => {
  // Private
  let users = []
  let nextId = 1

  // Public API
  return {
    create(name, email) {
      const user = { id: nextId++, name, email }
      users.push(user)
      return user
    },
    getAll() {
      return [...users]  // Return a copy — can't modify internal array
    },
    findById(id) {
      return users.find(u => u.id === id)
    },
    delete(id) {
      users = users.filter(u => u.id !== id)
    }
  }
})()

userService.create('Alice', 'alice@example.com')
userService.create('Bob', 'bob@example.com')
console.log(userService.getAll())  // Both users
// users array is completely private!

Closures in React — useState

React's useState hook is powered by closures:

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

  // This function closes over 'count'
  const handleClick = () => {
    setCount(count + 1)  // Uses the closed-over count
  }

  return <button onClick={handleClick}>{count}</button>
}

And the classic stale closure bug:

// ❌ Stale closure bug
function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1)  // 'count' is closed over from initial render!
      // Always increments from 0 → always stays at 1
    }, 1000)
    return () => clearInterval(interval)
  }, [])  // Empty deps — the closure captures the initial count

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

// ✅ Fix: Use the functional update form
useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1)  // Uses the LATEST count, no closure issue
  }, 1000)
  return () => clearInterval(interval)
}, [])

Closure-Based Memoization

function memoize(fn) {
  const cache = {}  // Closed over by the returned function

  return function(...args) {
    const key = JSON.stringify(args)
    if (key in cache) {
      console.log('Cache hit!')
      return cache[key]
    }
    cache[key] = fn(...args)
    return cache[key]
  }
}

const expensiveCalc = memoize((n) => {
  console.log(`Computing ${n}...`)
  return n * n
})

expensiveCalc(5)   // Computing 5...  → 25
expensiveCalc(5)   // Cache hit!      → 25 (no computation)
expensiveCalc(10)  // Computing 10... → 100

Closure-Based Debounce

function debounce(fn, delay) {
  let timer  // Closed over!

  return function(...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
}

const handleSearch = debounce((query) => {
  console.log(`Searching: ${query}`)
}, 500)

handleSearch('h')
handleSearch('he')
handleSearch('hel')
handleSearch('hell')
handleSearch('hello')  // Only this one fires — 500ms after last call

Conclusion

Closures are everywhere in JavaScript — React hooks, event handlers, module patterns, debouncing, memoization. Once you understand that a function remembers the scope where it was created, closures stop being mysterious and start being your favorite tool. They're the key to encapsulation, private state, and elegant functional patterns in JavaScript.