LWC imperative Apex call vs wire decorator
When to use `@wire` (reactive, cached, refresh-friendly via `refreshApex`) vs imperative `callApex()` (mutations, fire-and-forget) in Lightning Web Components, and how Lightning Data Service cache invalidation differs between the two.
LWC @wire vs Imperative Apex: When to Use Each (and How the Cache Really Works)
A few months ago I was pairing with a teammate on what should have been a five-minute bug. A list of accounts on her LWC refused to update after the user created a new one. She'd added refreshApex. She'd added a custom event. She'd even tried reloading the page in despair. The fix took us forty minutes, and by the end I realized I didn't actually understand the cache I was supposedly invalidating. This article is the version of that conversation I wish I'd had on paper before we started.
Every Lightning Web Component that talks to the server picks one of two paths. You either decorate a property or method with @wire and let the framework hand you results, or you import the Apex method as a function and call it imperatively. Both look like one-line decisions in the docs. In practice they have very different caching semantics, very different failure modes when data changes, and very different costs when you need to refresh after a DML write.
I'll walk through both patterns, show how Lightning Data Service (LDS) sits underneath them, and give you a concrete rule of thumb for picking one over the other. The short version: @wire is for reading reactive, cacheable, refreshable data, and imperative calls are for mutations, fire-and-forget operations, and any flow where you need to control exactly when the round-trip happens.
The two import shapes
A colleague once spent an entire afternoon flipping one component back and forth between @wire and an imperative call, convinced the bug was somewhere in her Apex method. It wasn't — and the reason she kept missing it is that both patterns start the same way. You expose an Apex method with @AuraEnabled(cacheable=true) (or without cacheable=true for non-cacheable reads and for mutations), then import it into the LWC module.
// accountList.js
import { LightningElement, wire } from 'lwc';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
export default class AccountList extends LightningElement {
// Wire pattern: framework calls getAccounts and pushes the result.
@wire(getAccounts, { limit: 10 })
accounts;
}
// accountForm.js
import { LightningElement } from 'lwc';
import createAccount from '@salesforce/apex/AccountController.createAccount';
export default class AccountForm extends LightningElement {
// Imperative pattern: you call the function when you want to.
async handleSubmit(event) {
event.preventDefault();
const { name, industry } = this.formValues;
try {
const id = await createAccount({ name, industry });
this.recordId = id;
} catch (error) {
this.error = error;
}
}
}
Look closely at what each version signed up for. The wire version registered a subscription. The framework owns the lifecycle of the call. The imperative version registered nothing. You own the lifecycle, which means you also own the cache-invalidation problem when the underlying data changes. That ownership split is the whole story.
What @wire actually does
What is the framework actually doing between the moment you decorate a property with @wire and the moment that data shows up in your template? Three things, in this order:
- The framework inspects the parameter object and creates a config fingerprint.
- It hands the fingerprint to Lightning Data Service, which checks its in-memory record cache.
- If the cache has a hit, the property updates synchronously on the next microtask. If not, LDS makes the network call, then writes the response back into the cache for every component that wired the same Apex method with the same arguments.
This is why @AuraEnabled(cacheable=true) matters. Without it, the wire still works, but LDS will not cache the result. You get the reactive subscription without the deduplication benefit. With cacheable=true, two components that wire getAccounts({ limit: 10 }) on the same page issue one network call between them, not two. That's a meaningful win on a dashboard page where the same lookup shows up in five places.
The reactive part comes from the config object. If the parameters reference an @api or @track property prefixed with $, the wire re-fires whenever that property changes:
@api recordId;
@wire(getRelatedContacts, { accountId: '$recordId' })
contacts;
Change recordId from the parent and contacts updates. You don't write any componentDidUpdate equivalent. You also don't get a hook to run side effects between renders, which is one of the friction points that pushes developers toward the imperative pattern.
The @wire callback variant exists for that exact case:
@wire(getRelatedContacts, { accountId: '$recordId' })
wiredContacts({ error, data }) {
this._wiredResult = arguments[0]; // keep handle for refreshApex
if (data) {
this.contacts = data.map(c => ({ ...c, label: c.Name }));
} else if (error) {
this.error = error;
}
}
The callback form gives you a place to transform data, but it costs you the automatic property hand-off. You also need to stash the arguments[0] reference manually if you want refreshApex to work later. More on that in a minute.
What imperative callApex() actually does
The function half the LWC community calls callApex() does not actually exist in the framework. The pattern is shorthand for call the imported Apex function directly. When you do this:
import deleteAccount from '@salesforce/apex/AccountController.deleteAccount';
async function remove(id) {
await deleteAccount({ accountId: id });
}
The framework issues an XHR to /services/data/vXX.0/aura?aura.RecordsUiController.executeAction. The response is not cached, regardless of whether the method is marked cacheable=true. Imperative calls always hit the server. They're also the only legal way to call a method that is NOT cacheable=true, which is most mutation methods.
The trade-off is simple. You pay a guaranteed round-trip every time, and you give up automatic reactivity. In return, you get explicit control over when the call fires, what happens on success, what happens on failure, and how the result flows back into component state. For a button click that creates a record, that explicit control is exactly what you want.
Lightning Data Service and the shared cache
LDS is the layer that makes @wire interesting. Think of it as a per-page cache keyed on the request fingerprint. The cache is shared across all components on the page, which is the deduplication mechanism. It's also the layer that makes getRecord and getRecords (the UI API wires) automatically update when any component on the page modifies the record through LDS.
Here's the catch, and it's a big one: Apex methods do not participate in the record-level reactivity. LDS caches getAccounts({ limit: 10 }) under its argument fingerprint, but if a different component writes to the Account object through Apex or through a manual REST call, LDS has no way to know the cached result is stale. The wire keeps showing the cached data until something explicitly invalidates it.
This is the single most-asked question on the LWC Stack Exchange. "Why doesn't my wired list update after I create a new record?" The answer is that the wire is reading from a cache that nobody told to invalidate. Once you see that framing, half the related questions answer themselves.
There are three ways to fix it:
- Call
refreshApex(this._wiredResult)after the mutation completes. - Update the parameter that the wire depends on, which re-fires the wire with a new fingerprint.
- Use
getRecordNotifyChange([recordId])for cases where LDS does track the record (UI API wires), to nudge LDS into refetching.
For Apex wires, option 1 is the canonical answer. It's also the reason callback wires need to capture arguments[0].
refreshApex and how cache invalidation really works
refreshApex is imported from lightning/uiRecordApi and takes the full wire result object, not just the data:
import { refreshApex } from '@salesforce/apex';
@wire(getRelatedContacts, { accountId: '$recordId' })
wiredContacts(result) {
this._wiredResult = result;
if (result.data) {
this.contacts = result.data;
}
}
async handleDelete(contactId) {
await deleteContact({ contactId });
await refreshApex(this._wiredResult);
}
refreshApex does two things. It invalidates the LDS cache entry for that exact fingerprint, then refetches it. Every component on the page wired to the same Apex method with the same arguments will see the refreshed data on the next render cycle. This is the part developers often miss: the refresh is global to the page, not local to the component that called it. That's usually what you want, but it means a sloppy refreshApex call can trigger unexpected refetches in sibling components you forgot were on the page.
The cache key includes every argument. refreshApex on { accountId: 'a' } does nothing for a wire on { accountId: 'b' }. If a record-create flow needs to refresh a list, the list wire needs to be on the same arguments, or the list needs its own refresh strategy.
For UI API wires (getRecord, getRecordUi, getRelatedListRecords), LDS automatically refetches when any component on the page modifies the record via LDS or via updateRecord. This is why lightning-record-edit-form "just works" with cards on the same page that wire getRecord for the same ID. It's also why people incorrectly expect Apex wires to behave the same way. They don't.
A worked example: list + create flow
This is the pattern that catches most people, and it's exactly where my teammate and I got stuck. A parent component renders a list, a child component creates a record, and the list should update after creation.
// accountManager.js
import { LightningElement, wire } from 'lwc';
import { refreshApex } from '@salesforce/apex';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
export default class AccountManager extends LightningElement {
wiredAccountsResult;
accounts = [];
@wire(getAccounts, { limit: 25 })
wiredAccounts(result) {
this.wiredAccountsResult = result;
if (result.data) {
this.accounts = result.data;
}
}
async handleAccountCreated() {
await refreshApex(this.wiredAccountsResult);
}
}
<!-- accountManager.html -->
<template>
<c-account-form oncreated={handleAccountCreated}></c-account-form>
<ul>
<template for:each={accounts} for:item="a">
<li key={a.Id}>{a.Name}</li>
</template>
</ul>
</template>
The child fires a created custom event after the imperative createAccount resolves. The parent catches the event and calls refreshApex. LDS invalidates its cache for getAccounts({ limit: 25 }), refetches, and the list re-renders. No manual array manipulation, no optimistic update gymnastics.
The version of this code that does NOT work is the one where the parent uses @wire(getAccounts, { limit: 25 }) accounts (the property form). That form doesn't give you a result object to pass to refreshApex, so you can't invalidate the cache. You can either switch to the callback form or you can change a wire parameter to force a refetch. The callback form is the cleaner answer, and it's the one I now reach for by default whenever I suspect a refresh will be needed later.
When to pick which
A simple rule covers about 80% of cases.
Use @wire when:
- You're reading data, not writing.
- The Apex method is
@AuraEnabled(cacheable=true). - The data is keyed on parameters the component already has (an
@api recordId, a public property, a user-selected filter). - You want automatic deduplication across sibling components on the same page.
- You want reactive updates when input parameters change.
Use imperative calls when:
- You're doing DML or any non-cacheable mutation.
- You need to fire the call at a specific moment (button click, timer tick, navigation event).
- You need fine-grained error handling around the call, distinct from the render lifecycle.
- The result is single-use and not worth caching.
- You need to chain server calls, where each call's arguments depend on the previous response.
A useful tie-breaker: if reloading the page would cause the data to come back exactly as it is now, @wire is probably right. If reloading the page would lose work in progress, imperative is right.
Performance differences worth knowing
A cacheable=true Apex method called through @wire hits the network on the first wire on the page. Every subsequent wire on that page with the same arguments is served from the LDS cache, which resolves in roughly 1-3ms versus 80-300ms for the network call. For a list rendered in 10 cards that each wire the same lookup, that's one round-trip instead of ten. The difference is invisible on a fast local dev org and very visible on a sandbox in the middle of a workday.
Imperative calls don't get this benefit. Ten components calling the same imperative Apex method on mount issue ten network calls. If you find yourself reaching for imperative because the callback gives you a place to transform the response, consider the callback form of @wire instead. You get the transformation hook and the cache.
For mutations the comparison doesn't apply. There's no cache for a createAccount call, and the imperative form is the only legal choice for a non-cacheable=true method.
Common pitfalls
- Forgetting to capture the wire result for
refreshApex. The property form of@wirediscards the result object. Switch to the callback form when you need refresh control. - Calling
refreshApexwith the wrong arguments. Cache keys are per-argument-set.refreshApex(this.wiredAccounts)only refreshes that exact parameter combination. - Marking a mutation method
cacheable=true. This is illegal at runtime.cacheable=truerequires the method to be free of side effects. - Expecting
getRecordNotifyChangeto work for Apex wires. It does not. That API is for nudging LDS to refetch UI API records, not arbitrary Apex results. - Wiring on parameters that change every render. A wire whose config object has a new identity on every render will refire every render. Use
@trackon the parameter object or expose stable getters.
A note on error handling
Both patterns surface errors, but in different shapes. Wire errors arrive in the error property of the destructured result and persist across renders until the next successful fetch. Imperative errors come back as a rejected promise, with the standard { body: { message }, statusText } shape. Treat them differently in the UI. Wire errors usually warrant an inline empty-state. Imperative errors usually warrant a toast or a form-level error message, because they're tied to a specific user action.
References
- https://developer.salesforce.com/docs/platform/lwc/guide/apex.html
- https://developer.salesforce.com/docs/platform/lwc/guide/apex-wire-method-reference.html
- https://developer.salesforce.com/docs/platform/lwc/guide/apex-call-imperative.html
- https://developer.salesforce.com/docs/platform/lwc/guide/reference-refresh-apex.html
- https://developer.salesforce.com/docs/platform/lwc/guide/data-ui-api.html
- https://github.com/salesforce/lwc