LearnFrenzy
Sign in Take a free test

Functions, this and Closures · Lesson 6 of 25

Closures and Debounce in LWC

6 min read Updated Nov 8, 2026 Saurabh Samir

A closure is a function that remembers the variables from the place it was created, even after that code has finished running. It sounds academic, but it's what makes debounced search boxes, one-time initialisers and caches possible – three things almost every serious LWC project needs.

In this lesson

  • What a closure is, step by step
  • Private state with closures
  • Writing debounce, once and memoize
  • A debounced Apex search component

A closure, step by step

function makeIdGenerator(prefix) {
    let next = 1;                              // lives on after makeIdGenerator returns
    return () => `${prefix}-${String(next++).padStart(3, '0')}`;
}

const caseId = makeIdGenerator('CASE');
const taskId = makeIdGenerator('TASK');

console.log(caseId(), caseId(), taskId(), caseId());

Output

CASE-001 CASE-002 TASK-001 CASE-003

Each generator keeps its own variables

  • caseIdprefix 'CASE', next 4its own closure
  • taskIdprefix 'TASK', next 2a separate closure

Each call to makeIdGenerator creates a fresh next variable. The returned arrow function keeps a live link to it – that link is the closure. Nothing outside can read or change next directly, which makes closures a simple way to keep state private.

Debounce: wait until the user stops typing

A search box that calls Apex on every keystroke sends a request for "A", "Ac", "Acm" and "Acme" – four server round trips, wasted Apex time, and results that can arrive out of order. Debounce waits until the input has been quiet for a moment and then runs once:

function debounce(fn, delay) {
    let timerId;                               // shared by every call through the closure
    return (...args) => {
        clearTimeout(timerId);                 // cancel the previous pending call
        timerId = setTimeout(() => fn(...args), delay);
    };
}

const search = debounce((term) => console.log(`searching for "${term}"`), 300);
search('A');
search('Ac');
search('Acme');                                 // only this one runs, 300ms after the last call
setTimeout(() => search('Globex'), 500);        // a new pause → runs again

Output

searching for "Acme"
searching for "Globex"

once and memoize

function once(fn) {
    let done = false;
    let result;
    return (...args) => {
        if (!done) {
            done = true;
            result = fn(...args);
        }
        return result;
    };
}

function memoize(fn) {
    const cache = new Map();
    return (key) => {
        if (!cache.has(key)) cache.set(key, fn(key));
        return cache.get(key);
    };
}

const loadSettings = once(() => {
    console.log('loading settings…');
    return { currency: 'INR' };
});
console.log(loadSettings(), loadSettings());

const slowLabel = memoize((stage) => {
    console.log(`computing label for ${stage}`);
    return stage.toUpperCase().replaceAll(' ', '_');
});
console.log(slowLabel('Closed Won'), slowLabel('Closed Won'), slowLabel('Proposal'));

Output

loading settings…
{ currency: 'INR' } { currency: 'INR' }
computing label for Closed Won
computing label for Proposal
CLOSED_WON CLOSED_WON PROPOSAL

Inside a component, the class instance already gives you private-ish state (this.cache), so you'll mostly use closures in shared utility modules – a debounce helper exported from c/utils, for example.

Here's the real thing: the component waits 300ms after the last keystroke, then calls an Apex method imperatively.

// AccountSearchController.cls
public with sharing class AccountSearchController {
    @AuraEnabled(cacheable=true)
    public static List<Account> searchAccounts(String term) {
        String pattern = '%' + term + '%';
        return [SELECT Id, Name, Industry FROM Account WHERE Name LIKE :pattern WITH USER_MODE ORDER BY Name LIMIT 10];
    }
}
<!-- accountSearch.html -->
<template>
    <lightning-input type="search" label="Search accounts" value={term} onchange={handleChange}></lightning-input>
    <template for:each={accounts} for:item="acc">
        <p key={acc.Id}>{acc.Name} · {acc.Industry}</p>
    </template>
    <p>{status}</p>
</template>
// accountSearch.js
import { LightningElement } from 'lwc';
import searchAccounts from '@salesforce/apex/AccountSearchController.searchAccounts';

const DELAY = 300;

export default class AccountSearch extends LightningElement {
    term = '';
    accounts = [];
    status = 'Type at least 2 letters';
    timerId;

    handleChange(event) {
        this.term = event.target.value;
        clearTimeout(this.timerId);
        if (this.term.length < 2) {
            this.accounts = [];
            this.status = 'Type at least 2 letters';
            return;
        }
        this.status = 'Searching…';
        // eslint-disable-next-line @lwc/lwc/no-async-operation
        this.timerId = setTimeout(() => this.runSearch(this.term), DELAY);
    }

    async runSearch(term) {
        try {
            const result = await searchAccounts({ term });
            if (term !== this.term) return;          // the user kept typing: ignore stale results
            this.accounts = result;
            this.status = `${result.length} found`;
        } catch (error) {
            this.status = 'Search failed';
        }
    }

    disconnectedCallback() {
        clearTimeout(this.timerId);
    }
}

Result

Search accounts []
Type at least 2 letters

› type “A” into Search accounts
Search accounts [A]
Type at least 2 letters

› type “c” into Search accounts
Search accounts [Ac]
Searching…

› type “m” into Search accounts
Search accounts [Acm]
Searching…

› type “e” into Search accounts
Search accounts [Acme]
Searching…

› wait 350ms
Search accounts [Acme]
Acme Corp · Energy
Acme Labs · Biotech
2 found

searchAccounts called 1× with {"term":"Acme"}

Four keystrokes, one Apex call. The timer id lives on this rather than in a closure, because the component instance is already the natural home for its state. Note the term !== this.term check too: if a slow response arrives after the user has typed something else, it is dropped instead of overwriting newer results.

Debounce or throttle?

Debounce runs once after activity stops – search boxes, auto-save. Throttle runs at most once per interval while activity continues – scroll or resize handlers, progress updates.

Practice

Try it yourself

  1. What does this print, and why?
    const fns = []; for (let i = 0; i < 3; i++) fns.push(() => i); console.log(fns.map((f) => f()));

    Show answer

    [ 0, 1, 2 ]. let creates a new i for each loop iteration, and each arrow closes over its own copy. With var all three would share one variable and print [ 3, 3, 3 ].

  2. An auto-save component sends a save request after every character typed in a long description. Which helper fixes it, and with what delay?

    Show answer

    Debounce the save – something like 800–1000ms works well for auto-save, longer than for search, so a save happens when the user pauses.

Quick check

What is a closure?

A function plus the variables it remembers from where it was created.

Why debounce a search box?

To call the server once after typing pauses instead of on every keystroke.

What must you do with a pending timer when the component is removed?

Clear it in disconnectedCallback.

Summary

  • Closures let functions keep private, live state.
  • debounce, once and memoize are small closures with big payoffs.
  • Debounce Apex searches and ignore stale responses.
  • Clear timers when the component disconnects.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links