@wire adapters or imperative Apex calls with client-side debouncing, the component queries matching records without firing excessive server requests on every single keystroke.
Search bars and auto-complete lookup inputs are essential building blocks of modern enterprise Salesforce applications. Building a performant search feature requires balancing two competing priorities: giving the user an instant, reactive experience while protecting your Salesforce org from governor limit bottlenecks and unnecessary Apex execution loops.
1. Search Architecture: Wire Adapters vs. Imperative Apex
Salesforce developers generally choose between two approaches when querying data from an LWC:
- Reactive Wire Service (
@wire): Automatically provisions and caches search results whenever a reactive parameter (e.g.,'$searchTerm') changes. Built-in client caching minimizes duplicate server roundtrips. - Imperative Apex Calls (
searchRecords()): Invoked on demand inside an event handler or promise chain. Gives explicit programmatic control over loading spinners, error states, and execution timing. - Debounce Optimization: A crucial JavaScript timing pattern that delays sending the Apex query until the user stops typing for a designated interval (typically 300ms), eliminating redundant SOQL queries during rapid typing.
- Apex Configuration:
@AuraEnabled(cacheable=true)withWITH USER_MODE. - SOQL Safeguard: Bind variable with wildcards (
LIKE :key) and strictLIMITclauses. - Frontend Optimization:
window.clearTimeout()debounce mechanism. - UI Component:
<lightning-input type="search">with clear button support.
2. Step-by-Step Implementation
Below is a production-grade implementation combining a secure, user-mode Apex controller with a debounced, reactive LWC search interface.
RecordSearchController.cls)Sanitize input values, enforce user-mode security, and apply query limits.
public with sharing class RecordSearchController {
@AuraEnabled(cacheable=true)
public static List<Account> searchAccounts(String searchKey) {
if (String.isBlank(searchKey) || searchKey.trim().length() < 2) {
return new List<Account>();
}
// Format wildcard search key safely
String formattedKey = '%' + searchKey.trim() + '%';
return [
SELECT Id, Name, Industry, Phone, Type
FROM Account
WHERE Name LIKE :formattedKey
WITH USER_MODE
ORDER BY Name ASC
LIMIT 20
];
}
}
accountSearch.html)Use SLDS search inputs, loading indicators, and clean list layouts.
<template>
<lightning-card title="Account Quick Search" icon-name="standard:account">
<div class="slds-p-around_medium">
<!-- Search Input with Debounce -->
<lightning-input
type="search"
label="Search Accounts"
placeholder="Type at least 2 characters..."
value={searchKey}
onchange={handleKeyChange}>
</lightning-input>
<!-- Loading Spinner -->
<template lwc:if={isLoading}>
<div class="slds-is-relative slds-m-vertical_large">
<lightning-spinner alternative-text="Searching..." size="small"></lightning-spinner>
</div>
</template>
<!-- Results List -->
<template lwc:if={hasResults}>
<ul class="slds-has-dividers_bottom-space slds-m-top_medium">
<template for:each={accounts} for:item="acc">
<li key={acc.Id} class="slds-item slds-p-vertical_x-small">
<div class="slds-grid slds-grid_align-spread">
<div>
<p class="slds-text-heading_small"><strong>{acc.Name}</strong></p>
<p class="slds-text-body_small slds-text-color_weak">
Industry: {acc.Industry} • Phone: {acc.Phone}
</p>
</div>
<div class="slds-badge slds-theme_light">{acc.Type}</div>
</div>
</li>
</template>
</ul>
</template>
<!-- Empty Results State -->
<template lwc:elseif={noResultsFound}>
<div class="slds-text-align_center slds-m-top_medium slds-text-color_weak">
No accounts found matching "{searchKey}".
</div>
</template>
</div>
</lightning-card>
</template>
accountSearch.js)Apply debouncing and wire the reactive parameter.
import { LightningElement, wire, track } from 'lwc';
import searchAccounts from '@salesforce/apex/RecordSearchController.searchAccounts';
const DEBOUNCE_DELAY = 300; // milliseconds
export default class AccountSearch extends LightningElement {
searchKey = '';
debouncedSearchKey = '';
debounceTimer;
isLoading = false;
accounts = [];
error;
// Reactive wire adapter automatically fetches when debouncedSearchKey updates
@wire(searchAccounts, { searchKey: '$debouncedSearchKey' })
wiredAccounts({ error, data }) {
this.isLoading = false;
if (data) {
this.accounts = data;
this.error = undefined;
} else if (error) {
this.error = error;
this.accounts = [];
console.error('Search error:', error);
}
}
get hasResults() {
return this.accounts && this.accounts.length > 0;
}
get noResultsFound() {
return !this.isLoading &&
this.debouncedSearchKey.trim().length >= 2 &&
(!this.accounts || this.accounts.length === 0);
}
handleKeyChange(event) {
this.searchKey = event.target.value;
this.isLoading = true;
// Reset the timer on every keystroke (Debounce)
window.clearTimeout(this.debounceTimer);
this.debounceTimer = window.setTimeout(() => {
this.debouncedSearchKey = this.searchKey;
if (this.debouncedSearchKey.trim().length < 2) {
this.isLoading = false;
this.accounts = [];
}
}, DEBOUNCE_DELAY);
}
}
accountSearch.js-meta.xml)
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__AppPage</target>
<target>lightning__RecordPage</target>
<target>lightning__HomePage</target>
<target>lightningCommunity__Page</target>
</targets>
</LightningComponentBundle>
3. Common Traps & Performance Best Practices
Calling an imperative Apex method on every raw
onchange event triggers an individual server roundtrip for every single character typed. If a user quickly types a 10-character name, the component fires 10 simultaneous Apex transactions. This leads to governor limit exhaustion and race conditions where older requests overwrite newer search results. Always wrap real-time search inputs in a 300ms debounce timer.
Writing raw dynamic queries such as
Database.query('SELECT Id FROM Account WHERE Name LIKE \'%' + key + '%\'') opens security vulnerabilities. Always use static SOQL with parameterized bind variables (WHERE Name LIKE :formattedKey) and append WITH USER_MODE to respect Field-Level Security and sharing rules.
- Add a Clear Button: Using
<lightning-input type="search">automatically provides a built-in clear ("x") icon that wipes the input and clears results seamlessly. - Apply Sensible Limits: Always include a
LIMIT 20orLIMIT 50clause in search queries to keep query execution times low and memory consumption minimal. - Handle Empty States: Clearly distinguish between initial idle states (user hasn't typed yet), loading states (spinner active), and zero-result states to maintain a smooth user experience.
Summary
Implementing real-time search in Lightning Web Components provides a seamless, dynamic user experience across Salesforce apps. By combining cacheable Apex methods, user-mode query security, and client-side debounce timers, developers can deliver fast, responsive search tools that protect platform resources and scale effortlessly.
Comments
Questions, corrections and your own experience are all welcome.
Post a Comment