The wire service is one of those LWC features that looks simple from the outside β you decorate a property or function with @wire and data appears β but has enough depth underneath that I regularly see developers run into unexpected behavior because they treated it as a black box. This guide covers everything from the fundamentals to the edge cases that matter in production.
What the Wire Service Actually Does
At its core, the wire service is a reactive data provisioning mechanism. It connects a wire adapter (a data source) to a component property or function. When the inputs to the wire adapter change, the wire service automatically re-invokes the adapter and delivers new data to your component.
The key word is reactive. You do not call the wire service β you declare a binding, and the runtime manages when and how data flows.
Wire Adapters
getRecord
The most commonly used wire adapter retrieves a single record by ID:
import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
import BILLING_CITY_FIELD from '@salesforce/schema/Account.BillingCity';
const FIELDS = [NAME_FIELD, INDUSTRY_FIELD, BILLING_CITY_FIELD];
export default class AccountSummary extends LightningElement {
@api recordId;
@wire(getRecord, { recordId: '$recordId', fields: FIELDS })
account;
get name() {
return getFieldValue(this.account.data, NAME_FIELD);
}
get industry() {
return getFieldValue(this.account.data, INDUSTRY_FIELD);
}
get isLoading() {
return !this.account.data && !this.account.error;
}
}The $recordId prefix on the parameter is what makes it reactive β when the recordId property changes, the wire service automatically re-fetches. Always use imported schema references (@salesforce/schema/Object.Field) rather than string literals for field names. Schema references are validated at compile time and trigger re-wire when they change.
getRecordUi
getRecordUi returns not just record data but also the object metadata, layout information, and picklist values in a single call:
import { getRecordUi } from 'lightning/uiRecordApi';
@wire(getRecordUi, {
recordIds: '$recordId',
layoutTypes: ['Full'],
modes: ['View']
})
recordUi;
get fieldLabel() {
if (!this.recordUi.data) return '';
const objectInfo = this.recordUi.data.objectInfos.Account;
return objectInfo.fields.Industry.label;
}getRecordUi is heavier than getRecord β only use it when you genuinely need layout or metadata information.
Custom Apex Wire Adapters
For complex queries that go beyond what the platform wire adapters support, you can wire to an Apex method:
JavaScript Component
import getRelatedOpportunities from '@salesforce/apex/AccountController.getRelatedOpportunities';
@wire(getRelatedOpportunities, { accountId: '$recordId', stage: '$selectedStage' })
opportunities;Apex Controller
public class AccountController {
@AuraEnabled(cacheable=true)
public static List<Opportunity> getRelatedOpportunities(
Id accountId,
String stage
) {
return [
SELECT Id, Name, Amount, StageName, CloseDate
FROM Opportunity
WHERE AccountId = :accountId
AND StageName = :stage
ORDER BY CloseDate ASC
LIMIT 100
];
}
}The cacheable=true annotation is required for wire service usage. This tells the platform that the methodβs results can be safely cached and shared. Methods with cacheable=true cannot perform DML β they are read-only.
Reactive Properties
The $ prefix on a wire parameter creates a reactive binding. When that property changes, the wire service re-runs the adapter automatically.
export default class OpportunityFilter extends LightningElement {
@api recordId;
selectedStage = 'Prospecting'; // changes trigger re-wire
@wire(getOpportunitiesByStage, {
accountId: '$recordId',
stage: '$selectedStage'
})
opportunities;
handleStageChange(event) {
this.selectedStage = event.detail.value;
// No manual re-fetch needed β wire re-runs automatically
}
}Using getter-based reactive parameters
You can also make reactive parameters from computed values using getter-based objects:
get opportunityFilters() {
return {
accountId: this.recordId,
stage: this.selectedStage,
minAmount: this.minAmount
};
}
@wire(getFilteredOpportunities, '$opportunityFilters')
opportunities;Error Handling
A wire result always arrives as an object with data and error properties. At any given moment exactly one of the following states is true:
- Both
dataanderrorareundefinedβ loading state datais populated,errorisundefinedβ successdataisundefined,erroris populated β error state
HTML Template
<template if:true={isLoading}>
<lightning-spinner alternative-text="Loading..."></lightning-spinner>
</template>
<template if:true={account.error}>
<div class="error-message">
{errorMessage}
</div>
</template>
<template if:true={account.data}>
<!-- render data -->
</template>JavaScript Controller
get isLoading() {
return !this.account.data && !this.account.error;
}
get errorMessage() {
if (!this.account.error) return '';
return this.account.error.body?.message || 'An error occurred';
}Do not assume that a missing error means success β during the loading phase, both data and error are undefined.
Caching Behavior and refreshApex
How LDS Caching Works
The Lightning Data Service maintains a client-side cache keyed by record ID and field list. When two components on the same page wire to the same record with the same fields, only one server request is made. The second component receives data from the cache.
This is a significant performance benefit for record-heavy pages. It also means that if a DML operation on the server modifies a record, LDS detects the version change and automatically notifies all wired components β they re-render with fresh data without you writing any refresh logic.
When to Use refreshApex
LDS cache auto-refresh handles most scenarios. You need refreshApex specifically when:
- You make an imperative DML call from the component and need to immediately reflect changes
- Your wire adapter is a custom Apex method (not an LDS adapter) that returns stale data after an operation
import { refreshApex } from '@salesforce/apex';
// Store the wired result object for later refresh
wiredOpportunitiesResult; // store reference
@wire(getRelatedOpportunities, { accountId: '$recordId' })
wiredOpportunities(result) {
this.wiredOpportunitiesResult = result; // store for refreshApex
if (result.data) {
this.opportunities = result.data;
} else if (result.error) {
this.error = result.error;
}
}
async handleSaveOpportunity(event) {
try {
await saveOpportunity({ opportunity: this.editedOpp });
await refreshApex(this.wiredOpportunitiesResult); // force re-fetch
} catch (error) {
this.error = error;
}
}Note the function-style wire handler in the example above β this pattern stores the full provisioned result object for refreshApex. However, refreshApex also works with property-style wire declarations: if you use @wire(...) opportunities;, you can call refreshApex(this.opportunities) by passing the wired property directly. The function-style approach simply gives you more control over separating data and error handling.
Wire vs Imperative Apex
This is the most common decision point for LWC developers.
A practical example contrasting both:
Wire β Auto-loads
// Loads on component init, refreshes when recordId changes
@wire(getAccountContacts, { accountId: '$recordId' })
contacts;Imperative β On user action
// Loads on search button click with user-entered query
async handleSearch() {
this.isLoading = true;
try {
this.searchResults = await searchContacts({
accountId: this.recordId,
searchTerm: this.searchTerm
});
} catch (error) {
this.error = error;
} finally {
this.isLoading = false;
}
}You can and should use both patterns in the same component β wire for the initial record context data, imperative for user-initiated operations.
Custom Wire Adapters
Beyond wiring to Apex, you can build custom wire adapters using the @salesforce/wire/service APIs. This is advanced territory but useful for building reusable data provisioning components:
Custom Wire Adapter Implementation
import { register, ValueChangedEvent } from '@lwc/wire-service';
const wireService = Symbol('customWireAdapter');
function CustomWireAdapter(dataCallback) {
this.dataCallback = dataCallback;
}
CustomWireAdapter.prototype.connect = function() {
// Called when component is connected to DOM
this.dataCallback({ data: null, error: null }); // initial loading state
this.fetchData();
};
CustomWireAdapter.prototype.update = function(config) {
// Called when config parameters change
this.config = config;
this.fetchData();
};
CustomWireAdapter.prototype.disconnect = function() {
// Called when component is removed β clean up subscriptions
};
CustomWireAdapter.prototype.fetchData = async function() {
try {
const data = await fetchFromSomeSource(this.config);
this.dataCallback({ data, error: undefined });
} catch (error) {
this.dataCallback({ data: undefined, error });
}
};
register(wireService, CustomWireAdapter);
export { wireService };Custom wire adapters are most useful for ISV packages that expose data provisioning APIs or for centralizing access to external APIs that multiple components need.
Scenario: A component wires to a custom Apex method that returns a list of related Opportunities. After the user saves a new Opportunity via an imperative callout, the wired list does not refresh β it still shows the old data.
Call refreshApex after the imperative save. With property-style wire (@wire(β¦) opportunities;), pass the wired property directly: await refreshApex(this.opportunities). Alternatively, use a function-style handler to store the provisioned result object: @wire(getRelatedOpportunities, { accountId: '$recordId' }) wiredOpportunities(result) { this.wiredResult = result; ... }, then call await refreshApex(this.wiredResult). Both approaches work β the function-style handler gives you more control over separating data and error handling.
When a parent component needs to share record data with multiple child components, wire to the record once in the parent and pass specific field values down as @api properties. This consolidates LDS requests to a single wire call rather than each child independently wiring to the same record β reducing both server calls and component complexity.
Performance Patterns
Batch your field requests: If five components on a page all wire to the same Account record but request different fields, LDS will make separate requests. Structure your components so a parent component wires to the record with all needed fields and passes data down to children as @api properties.
Avoid wiring in loops: If you have a list of records and each list item component wires independently to its record, you generate N wire calls. Prefer fetching all records in the parent and passing down data.
Use getRecordNotifyChange: After imperative DML that modifies a record, call getRecordNotifyChange([{ recordId }]) to notify LDS that the record is stale:
import { getRecordNotifyChange } from 'lightning/uiRecordApi';
async handleSave() {
await updateRecord({ fields: { Id: this.recordId, ...this.changes } });
getRecordNotifyChange([{ recordId: this.recordId }]);
}What patterns have you found most useful for managing complex wire service data flows in large LWC component trees?
Test Your Knowledge
How did this article make you feel?
Comments
Salesforce Tip