Functions, this and Closures · Lesson 6 of 25
Closures and Debounce in LWC
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());
CASE-001 CASE-002 TASK-001 CASE-003
Each generator keeps its own variables
caseIdprefix 'CASE', next 4its own closuretaskIdprefix '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
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'));
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.
A debounced Apex search
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);
}
}
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
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 ].letcreates a newifor each loop iteration, and each arrow closes over its own copy. Withvarall three would share one variable and print[ 3, 3, 3 ].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.