All insights

Platform

LWC wire service vs imperative Apex: how a component should get its data

A user saves a contact and the related list two components over still shows the old number. The cause is almost always the wrong data strategy. Here is when to use @wire, when to call Apex imperatively, when to let Lightning Data Service do it, and how to refresh the cache that is lying to your user.

LWC wire service vs imperative Apex: how a component should get its data, article illustration

A user edits a contact, hits save, and a component two tiles over still shows the old phone number. Nobody wrote a bug. The record changed in the database, and the screen is reading from a cache that was never told.

That is the whole subject of this post. An LWC has three ways to pull Salesforce data, each backed by a different cache with different refresh rules, and most “why is this stale” and “why won’t my wire fire” tickets come from using the wrong one.

My default is short. Use Lightning Data Service for a single record, @wire with a cacheable Apex method for read-only queries that should react to the page, and imperative Apex for writes and anything a user triggers. Pick wrong and you spend the sprint fighting the cache instead of using it.

Three ways in, three different caches

The wire service, imperative Apex, and Lightning Data Service are not three syntaxes for the same thing. They differ in who caches the result and who gets to invalidate it.

The wire service treats data as a subscription. Salesforce’s own anti-patterns post from August 2026 names the root mistake plainly: teams reach for @wire as a one-time fetch, when it is a stream that re-emits whenever its inputs change. Get that wrong and half the confusing behaviour follows.

So before you pick, ask what the component is doing. Reading one record, reading a query, or writing something. The answer picks the tool, and the tool picks how you refresh.

@wire is a subscription, not a fetch

@wire provisions an immutable stream of data to your component, as the wire service docs put it: each value is a newer version of the one before. You wire an adapter, and the service hands you data now and again every time the inputs change.

Those inputs are what make it reactive. In the config object, a value prefixed with $ points at a property on your component, and the wire re-provisions when that property changes. A value without $ is a static literal that never moves.

import { LightningElement, api, wire } from 'lwc';
import getContacts from '@salesforce/apex/ContactController.getContacts';

export default class ContactList extends LightningElement {
    @api recordId;            // a reactive class property
    contacts;
    error;

    @wire(getContacts, { accountId: '$recordId' })  // $ makes accountId reactive
    wiredContacts({ data, error }) {
        if (data) {
            this.contacts = data;
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.contacts = undefined;
        }
    }
}

Two traps live in that $. It only works on top-level values in the config object, so ['$accountIds'] inside an array is read as the literal string $accountIds, not your property. And the property has to be a real class field, or the change never reaches the wire. A wire that “won’t fire” is usually a missing $ or a plain variable the framework isn’t tracking.

The data you get back is read-only. Mutate it and you get Invalid mutation: ... is read-only. If you need to change it, copy it into a local property first and change the copy, never the wired field. That rule trips up everyone once.

cacheable=true is a promise you don’t write

To call an Apex method from @wire, the method has to be annotated @AuraEnabled(cacheable=true). That flag is a contract: the method only reads, never does DML. In exchange, Salesforce caches the result on the client and checks that cache before it troubles the server.

That client cache is the speed win and the stale-data risk in one. A second component asking for the same data gets it without a round trip. A record that changed somewhere else is not noticed, because the cache has no idea anything happened.

Keep cacheable methods selective and cheap. They run often and they run on the user’s critical path, so a non-selective SOQL query inside one is felt immediately, not eventually.

Imperative Apex for the things you trigger

Some work does not belong in a subscription. A write, a button handler, a call you only make under a condition, two calls in sequence where the second depends on the first. For all of it you call the Apex method imperatively, which returns a Promise you handle with async/await and a try/catch.

import { LightningElement, api } from 'lwc';
import saveRating from '@salesforce/apex/ContactController.saveRating';

export default class RateContact extends LightningElement {
    @api recordId;
    error;

    async handleSave(event) {
        try {
            await saveRating({ contactId: this.recordId, rating: event.detail.value });
            this.error = undefined;
        } catch (e) {
            this.error = e;
        }
    }
}

Imperative calls are not cached, which is the point when the method does DML or has to run exactly when the user clicks. The method here is not cacheable, because it writes. You also cannot refresh a cacheable method by calling it imperatively: a cacheable method is refreshed through @wire, not by a direct call.

One parameter detail that costs people an afternoon. Arguments go in a single object whose keys match the Apex parameter names, and Map is not a supported parameter type for either imperative or wired calls. Pass a list or a plain object instead.

Lightning Data Service for one record

When the job is a single record, writing Apex for it is usually the wrong move. Lightning Data Service reads and writes records with no Apex at all, through the getRecord wire adapter and the createRecord, updateRecord, and deleteRecord functions in lightning/uiRecordApi.

import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import PHONE_FIELD from '@salesforce/schema/Account.Phone';

export default class AccountCard extends LightningElement {
    @api recordId;

    @wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD, PHONE_FIELD] })
    account;

    get name()  { return getFieldValue(this.account.data, NAME_FIELD); }
    get phone() { return getFieldValue(this.account.data, PHONE_FIELD); }
}

LDS is built on the User Interface API, so its responses respect CRUD, field-level security, and sharing without you enforcing any of it in code. That is a real saving over a hand-written Apex controller, where FLS is yours to check and yours to get past a security review.

The bigger reason is the shared cache. LDS keeps one copy of a record for the whole page, so when one component updates it, the others reflect the change on their own. For plain single-record CRUD, reach for the lightning-record-form base component before you reach for any of this; drop to getRecord when you need a custom layout.

Why your data goes stale, and how to refresh it

Back to the contact that wouldn’t update. The fix depends on which cache is lying, and the two refresh tools are not interchangeable.

For data a cacheable Apex method provisioned through @wire, you refresh by calling refreshApex() with the whole wire result object you stashed, the object itself rather than its .data. So hold the provisioned result when the wire fires.

import { refreshApex } from '@salesforce/apex';
// ...
wiredResult;                       // keep the whole provisioned object

@wire(getContacts, { accountId: '$recordId' })
wired(result) { this.wiredResult = result; /* set data/error from result */ }

async handleRefresh() {
    await refreshApex(this.wiredResult);
}

For a record LDS is tracking, refreshApex does nothing. When that record changed outside LDS, say an imperative Apex write or a callout, you tell LDS with notifyRecordUpdateAvailable(recordIds) from lightning/uiRecordApi. It marks the record stale and every wire reading it refetches. The older getRecordNotifyChange is deprecated in its favour.

The rule I keep in my head: refreshApex for Apex wires, notifyRecordUpdateAvailable for LDS records. The stale phone number was an imperative write that never told LDS, and one notifyRecordUpdateAvailable after the save closed the ticket.

Errors arrive in two shapes

A wired result hands you { data, error } where exactly one side is populated, so you handle the else if (error) branch inline. An imperative call rejects its Promise, so the error lands in your catch. Either way the raw error is an awkward nested object, and the community-standard reduceErrors helper that flattens it into readable strings lives in the LWC Recipes sample app, not in the platform. Copy it in; don’t expect an import to exist.

If the read involves several related objects in one trip, pagination, or an aggregate, the GraphQL wire adapter is worth a look before you write Apex. The original lightning/uiGraphQLApi has been GA since Winter ‘24, and a newer lightning/graphql module arrived in Winter ‘26 as the recommended one. Both are read paths with the same client caching, so they slot in where @wire does.

The decision, the way I make it

Start with the record. One record to read or edit, and LDS does it with no Apex and no FLS code of your own. A query or a shape LDS can’t express, and a cacheable Apex method behind @wire gives you caching and reactivity for free. A write, a click handler, or anything conditional, and you go imperative with a try/catch you control.

Then decide the refresh before you ship, not after a tester files the stale-data bug. Name, for each piece of data on the screen, which cache holds it and which call invalidates it. If you can’t answer that for a field, you don’t yet know why it will go stale, and it will.

None of this is exotic. It is the same discipline the rest of the platform rewards: know where your data lives, know what reads it, and don’t let a component or an Apex method assume a cache will tell it the truth on its own.

Understanding the basics

When should I use @wire vs imperative Apex in LWC?

Use @wire for read-only data that should load with the component and react to changes in a reactive property, and whose Apex method is annotated cacheable=true. Use imperative Apex when the method writes data, runs on a user action like a button click, or has to run conditionally or in sequence. Imperative calls aren’t cached; wired cacheable calls are.

Why is my LWC @wire not firing?

The usual cause is the reactive parameter. A dynamic input has to be prefixed with $ and point at a real class property, as in { accountId: '$recordId' }. A missing $, a plain local variable the framework isn’t tracking, or a $ nested inside an array or object (where it’s read as a literal) all stop the wire from re-provisioning when the value changes.

How do I refresh cached data in an LWC?

For data provisioned by a cacheable Apex method through @wire, call refreshApex() with the stored wire result object. For a record cached by Lightning Data Service that changed elsewhere, call notifyRecordUpdateAvailable(recordIds). They target different caches, so using the wrong one does nothing.

Do I need Apex to read or edit a single record?

Usually not. Lightning Data Service reads and writes records through getRecord and the createRecord/updateRecord/deleteRecord functions, or the lightning-record-form base component, with no Apex to write or test. It also respects CRUD, field-level security, and sharing automatically, and keeps one shared copy of the record across components on the page.


Straightening out an LWC that shows stale data, fires the wrong number of server calls, or mixes three data strategies on one page? Talk to us. Making an org cheap and safe to change again is the work we do.

Keep reading

All insights