Salesforce Developers Blog

Data and Communication Anti-Patterns in Lightning Web Components

Avatar for Tim DionneTim Dionne
Learn how the wire service, public properties, and events move data through Lightning web components, and how to avoid reactivity and communication mistakes that cause stale UI, lost events, and hard-to-reproduce state bugs.
Data and Communication Anti-Patterns in Lightning Web Components
August 20, 2026

Most Lightning web component bugs fall into one of two categories: the component fetches the wrong data, or two components can’t talk to each other correctly. These problems are frustrating because the code looks right — the types are correct, there are no console errors — but the UI shows stale values, events disappear, or changes never propagate.

This is the second post in our series on LWC anti-patterns. The first post covered security and platform compatibility — how to write components that work within Lightning Web Security. In this post, you’ll learn the most common mistakes in three areas: the @wire service, public properties (@api), and event handling. Fixing these patterns is one of the highest-leverage things you can do to make your components more reliable.

Wire service anti-patterns

The wire service is how components reactively fetch Salesforce data, but its subscription model trips up developers who treat it like a one-time fetch. The patterns below cover the mistakes that lead to stale data, infinite loops, and updates that never arrive.

@wire is a subscription, not a fetch

Before diving into specific anti-patterns, there’s one mental model shift that explains most wire service bugs: @wire is not a fetch wrapper. It’s a subscription.

When you wire a property to getRecord (see docs), you’re not making a one-time request. You’re subscribing to a stream. The adapter can emit multiple times — once from cache, again when the network responds, and again when any component on the page modifies that record. Your wire handler should be safe to call multiple times with the most recent value.

This distinction matters for every pattern below.

Don’t mutate @wire results directly

Wire data is immutable. Assigning directly to a wired property, or to a nested field on that property, violates the contract with the wire service. The result is unpredictable: you may see stale data, missed reactive updates, or a render that doesn’t reflect your change.

1@wire(getContacts)
2contacts;
3
4handleDelete(event) {
5    // This won't work reliably — wire data is immutable
6    this.contacts.data = this.contacts.data.filter(c => c.Id !== event.detail.id);
7}

Copy the data into a local property instead, and mutate that.

1contacts = [];
2
3@wire(getContacts)
4wiredContacts({ data, error }) {
5    if (data) {
6        this.contacts = [...data];
7    }
8}
9
10handleDelete(event) {
11    this.contacts = this.contacts.filter(c => c.Id !== event.detail.id);
12}

Don’t update wire config in renderedCallback()

renderedCallback() (see docs) runs after every render. If you change a reactive wire config property inside it, that change triggers a new render, which calls renderedCallback() again. You’ve created an infinite loop.

1renderedCallback() {
2    // This causes an infinite render loop
3    this.recordId = this.template.querySelector('input').value;
4}

Update wire config properties in response to user events, not in rendering callbacks.

1handleIdChange(event) {
2    this.recordId = event.detail.value; // wire re-evaluates automatically
3}

Don’t assume wire adapters fire in order

Multiple wire adapters on the same component are independent subscriptions. The order they deliver data is not guaranteed, and neither is the number of times each fires. Code that reads from one wire result inside another wire’s handler, or reads wire data inside connectedCallback(), will fail silently when the Lightning Data Service (LDS) cache is cold.

1connectedCallback() {
2    // items.data is undefined on first load — this throws
3    this.total = this.items.data.reduce((sum, item) => sum + item.UnitPrice__c, 0);
4}

Drive derived state from inside the wire handler itself, and guard against undefined at every access.

1@wire(getOrderItems, { orderId: '$recordId' })
2wiredItems({ data }) {
3    if (data) {
4        this.items = data;
5        this.total = data.reduce((sum, item) => sum + item.UnitPrice__c, 0);
6    }
7}

If derived state depends on two adapters, compute it once both have delivered.

1updateTotal() {
2    if (this.order && this.items.length > 0) {
3        this.total = this.items.reduce((sum, item) => sum + item.UnitPrice__c, 0);
4    }
5}
6
7@wire(getOrder, { recordId: '$recordId' })
8wiredOrder({ data }) {
9    if (data) {
10        this.order = data;
11        this.updateTotal();
12   }
13}
14
15@wire(getOrderItems, { orderId: '$recordId' })
16wiredItems({ data }) {
17    if (data) {
18        this.items = data;
19        this.updateTotal();
20    }
21}

Use schema imports for object and field references

Hard-coding SObject or field names as strings bypasses Salesforce’s ability to validate references, prevent accidental deletion, and cascade renames. A renamed field silently breaks the component at runtime.

1// String literals — not validated at deploy time
2@wire(getRecord, { recordId: '$recordId', fields: ['Account.Name', 'Account.Industry'] })
3account;

Import field tokens from @salesforce/schema instead. The platform validates these at build time.

1import ACCOUNT_OBJECT from '@salesforce/schema/Account';
2import NAME_FIELD from '@salesforce/schema/Account.Name';
3import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
4
5@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD, INDUSTRY_FIELD] })
6account;

Use the correct field path in getRecord

Every field in a getRecord fields array must be reachable from the record’s own object type. Specifying a top-level field from a different object — like including Account.Name in a call for a Case record — produces a malformed config. The component receives incomplete data. Because Aura components share the same Lightning Data Service (LDS) cache, a malformed config can affect those Aura components too.

1// Wrong — Account.Name is not a field on Case
2@wire(getRecord, { recordId: '$recordId', fields: [CASE_SUBJECT, ACCOUNT_NAME] })
3record;

To access a related record’s field, use the full relationship path starting from the base object.

1import CASE_ACCOUNT_NAME from '@salesforce/schema/Case.Account.Name';
2
3// Correct — traversal expressed from Case
4@wire(getRecord, { recordId: '$recordId', fields: [CASE_SUBJECT, CASE_ACCOUNT_NAME] })
5record;

Don’t use @wire for write operations

@wire is for read operations only. Using it to invoke an Apex method that performs DML gives you unpredictable invocation timing and no way to handle errors in the UI.

1// Don't wire a method that performs DML
2@wire(saveRecord, { record: '$record' })
3savedResult;

Use imperative Apex calls for any operation that writes data.

1async handleSave() {
2    try {
3        await saveRecord({ record: this.record });
4        this.dispatchEvent(new CustomEvent('saved'));
5    } catch (error) {
6        // show error in UI
7    }
8}

Don’t mix Apex and LDS for the same record

Apex and Lightning Data Service (LDS) maintain separate, independent caches. When you fetch the same record through both at the same time, the two copies can diverge — one reflecting a recent server change, the other serving a stale value. The result is inconsistent UI state that’s hard to reproduce.

Pick one data source per record and use it consistently. Use LDS wire adapters when they support your use case. Use Apex only when LDS doesn’t (unsupported objects, complex queries, transactional operations).

Don’t use refreshApex() on LDS wire adapters

refreshApex()(see docs) is designed for wired Apex methods only. Calling it on an LDS adapter like getRecord is deprecated and has no reliable effect. LDS manages its own cache automatically when records change through LDS-aware operations.

1handleRefresh() {
2    refreshApex(this.record); // deprecated for LDS adapters
3}

To signal that a record’s cached data is stale after an out-of-band write, use notifyRecordUpdateAvailable() (see docs).

1import { notifyRecordUpdateAvailable } from 'lightning/uiRecordApi';
2
3async handleRefresh() {
4    await notifyRecordUpdateAvailable([{ recordId: this.recordId }]);
5    // LDS now has fresh data for all subscribed adapters
6}

All active wire adapters subscribed to that record will automatically receive fresh data.

Call notifyRecordUpdateAvailable() after imperative Apex writes

When an imperative Apex method modifies a record, LDS doesn’t know about it. Every LDS wire adapter on the page — including those in sibling and parent components — continues to serve its cached, pre-mutation value. The UI shows stale data with no error.

1async handleSave() {
2    await updateAccount({ recordId: this.recordId, name: this.name });
3    // LDS still has the old value — nothing updates here
4}

After the write completes, notify LDS that the record is stale.

1import { notifyRecordUpdateAvailable } from 'lightning/uiRecordApi';
2
3async handleSave() {
4    await updateAccount({ recordId: this.recordId, name: this.name });
5    await notifyRecordUpdateAvailable([{ recordId: this.recordId }]);
6    // LDS now has fresh data
7}

This keeps all LDS wire adapters across the page consistent with the server state.

Use config gating for dependent data, not promise chains

When loading data that depends on the result of another fetch — like fetching an Account after you have its ID from a Case — developers often reach for nested imperative Apex calls or Promise.then() (see docs) chains. This is fragile, doesn’t participate in LDS caching, and doesn’t recover automatically when upstream data changes.

The wire service handles this natively: a wire adapter won’t evaluate until all of its required config properties are truthy. Keep a reactive config property undefined until upstream data arrives, and the second adapter gates automatically.

1@api recordId;   // Case record ID
2accountId;       // undefined until first wire delivers
3
4@wire(getRecord, { recordId: '$recordId', fields: [CASE_ACCOUNT_ID] })
5wiredCase({ data }) {
6    if (data) {
7        this.accountId = data.fields.AccountId.value; // triggers second wire
8    }
9}
10
11// Does not evaluate until accountId is truthy
12@wire(getRecord, { recordId: '$accountId', fields: [ACCOUNT_NAME] })
13accountRecord;

Both records now participate in the LDS cache. If the Case’s AccountId changes, the second wire re-fetches the new Account automatically.

Don’t swap the config object to “re-fetch”

The @wire reactive system tracks individual top-level class properties, not nested object contents or the config object reference. Replacing the entire config object doesn’t trigger re-evaluation.

1currentConfig = { recordId: '001000000000001', fields: [NAME_FIELD] };
2
3@wire(getRecord, '$currentConfig')
4record;
5
6handleSelectRecord(event) {
7    // Swapping the object doesn't trigger re-evaluation
8    this.currentConfig = { recordId: event.detail.recordId, fields: [NAME_FIELD] };
9}

Declare reactive config values as individual top-level properties and reference them with $.

1recordId;
2
3@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD] })
4record;
5
6handleSelectRecord(event) {
7    this.recordId = event.detail.recordId; // wire re-evaluates automatically
8}

Don’t add dummy optionalField to bust the LDS cache

Some developers add irrelevant fields to optionalFields hoping to force a cache miss. This doesn’t work. optionalFields are fetched opportunistically when already in the cache and silently omitted when not. They don’t invalidate cache entries for required fields.

Use notifyRecordUpdateAvailable() after any out-of-band write to signal LDS that the record is stale. See the pattern above.

Design wire handlers to be idempotent

Because @wire is a subscription stream that can emit multiple times, wire handlers must be safe to call with a fresh value at any time. A handler that performs one-time initialization side effects will leave the component in a stale state after subsequent emissions.

1@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD] })
2wiredRecord({ data }) {
3    if (data) {
4        this.record = data;
5        this.initializeChart(data); // breaks on second emission
6    }
7}

Derive all displayed state from the most recent emission. For side effects that genuinely run once, guard them with a flag and keep them separate from the data-update logic:

1_chartInitialized = false;
2
3wiredRecord({ data }) {
4    if (data) {
5        this.record = data;
6        if (!this._chartInitialized) {
7            this._chartInitialized = true;
8            this.initializeChart(data);
9        }
10        this.updateChart(data); // idempotent update path
11
12    }
13}

Public property anti-patterns

Public properties (@api) define the contract between a component and its parent. Data flows down through them, and the patterns below cover the mistakes that break that one-way flow or leak your component’s internal state to the outside.

Don’t reassign @api properties internally

@api properties are owned by the parent component. They flow data downward. If you reassign an @api property from within your own component, you break the unidirectional data flow contract, which can cause infinite update cycles and runtime warnings from the LWC engine.

1@api value = '';
2
3handleInput(event) {
4    this.value = event.target.value; // reassigning an @api property
5}

Store the working value in a private property and expose a getter for the public interface.

1_value = '';
2
3@api
4get value() { return this._value; }
5set value(v) { this._value = v; }
6
7handleInput(event) {
8    this._value = event.target.value;
9    this.dispatchEvent(new CustomEvent('change', { detail: { value: this._value } }));
10}

Don’t pass objects by reference in event detail

JavaScript passes objects by reference. When you put an object directly into a CustomEvent detail (see docs), any listener can mutate the original. This creates hidden coupling and hard-to-debug state corruption.

1handleSave() {
2    this.dispatchEvent(new CustomEvent('save', { detail: this.record })); // mutable reference
3}

Pass a shallow copy so listeners can’t affect your component’s state.

1handleSave() {
2    this.dispatchEvent(new CustomEvent('save', { detail: { ...this.record } }));
3}

Event handling anti-patterns

Events carry data upward, from a component to its ancestors. The patterns below cover the mistakes that make events hard to listen for, leak mutable state between listeners, or quietly break component encapsulation.

Use CustomEvent, not Event

Unlike CustomEvent, the base Event constructor doesn’t support a detail property. Using it forces you to attach data through non-standard properties, which is fragile and inconsistent with platform conventions.

1this.dispatchEvent(new Event('recordselected'));
1this.dispatchEvent(new CustomEvent('recordselected', {
2    detail: { recordId: this.selectedId }
3}));

Don’t prefix event names with “on”

HTML attribute syntax uses on as a prefix for event handlers. If you name your custom event onstatuschange, a parent component must listen for it as ononstatuschange — which is confusing and risks conflicts with reserved event names.

1this.dispatchEvent(new CustomEvent('statuschange', { detail: { status } }));
1<c-child onstatuschange={handleStatusChange}></c-child>

Don’t mutate event.detail

event.detail is a shared object reference. Mutating it inside your handler modifies the object that other listeners in the same bubbling chain will read.

1handleStatusChange(event) {
2    event.detail.status = 'processed'; // affects other listeners
3    this.currentStatus = event.detail.status;
4}

Read from event.detail and store the value locally — never write back to it.

1handleStatusChange(event) {
2    this.currentStatus = event.detail.status;
3}

If you need to enrich event data for upstream listeners, stop the original event and re-dispatch a new one with the updated payload. This makes the transformation explicit.

1handleStatusChange(event) {
2    event.stopPropagation();
3    this.dispatchEvent(new CustomEvent('statuschange', {
4        bubbles: true,
5        detail: { ...event.detail, status: 'processed', processedAt: Date.now() }
6    }));
7}

Always remove event listeners you add imperatively

Listeners added via addEventListener (see docs) to elements outside your component’s template — like window (see docs) or document (see docs) — will hold a reference to your component after it disconnects. This prevents garbage collection and can fire callbacks on a disconnected component.

1connectedCallback() {
2    window.addEventListener('resize', this.handleResize.bind(this)); // never removed
3}

The cleanest fix is to declare the event handler as an arrow function class field, which is bound at class definition time and always has the same reference. Then, remove the event handler by calling removeEventListener() (see docs).

1handleResize = () => {
2    this.width = window.innerWidth;
3};
4
5connectedCallback() {
6    window.addEventListener('resize', this.handleResize);
7}
8
9disconnectedCallback() {
10    window.removeEventListener('resize', this.handleResize);
11}

Don’t call .bind() inline with addEventListener

.bind() (see docs) returns a new function object every time it’s called. If you pass this.handler.bind(this) to addEventListener and then pass this.handler.bind(this) again to removeEventListener, you’re passing two different functions. The listener is never removed.

1connectedCallback() {
2    window.addEventListener('scroll', this.handleScroll.bind(this));
3}
4
5disconnectedCallback() {
6    window.removeEventListener('scroll', this.handleScroll.bind(this)); // different function — does nothing
7}

Use an arrow function class field (same reference always) or store a single .bind() result in a property and reuse it.

1// Arrow field — preferred
2handleScroll = () => { this.scrollTop = window.scrollY; };
3
4// Or stored bind reference
5connectedCallback() { this._scrollHandler = this.handleScroll.bind(this); }

Don’t use bubbles: true, composed: true unneccessarily

Setting both bubbles (see docs) and composed (see docs) event properties to true causes the event to propagate through every shadow boundary all the way to the document root. This breaks encapsulation, creates implicit dependencies on ancestor components, and risks event name collisions higher in the tree. The LWC documentation is explicit: “Lightning web components don’t use this configuration.”

Use the default (bubbles: false, composed: false) for events handled by a direct parent. Use bubbles: true, composed: false only when a grandparent within the same shadow tree needs to hear the event. Reserve composed: true for rare cases where crossing a shadow boundary is genuinely necessary, and stop propagation at the first component that handles it.

Replace pubsub with Lightning Message Service

The community pubsub module is deprecated and no longer actively maintained. It only works within a single page, can’t cross namespace boundaries, and requires manual unregistration — a step that’s easy to forget and causes memory leaks.

Use Lightning Message Service — lightning/messageService — (see docs) instead. It works across LWC, Aura, and Visualforce components, spans multiple pages and namespaces, and manages subscription lifecycle cleanly.

1import { subscribe, unsubscribe, MessageContext } from 'lightning/messageService';
2import RECORD_SELECTED_CHANNEL from '@salesforce/messageChannel/RecordSelected__c';
3
4export default class RecordConsumer extends LightningElement {
5    @wire(MessageContext) messageContext;
6    _subscription;
7
8    connectedCallback() {
9        this._subscription = subscribe(
10            this.messageContext,
11            RECORD_SELECTED_CHANNEL,
12            (message) => this.handleRecordSelected(message)
13        );
14    }
15
16    disconnectedCallback() {
17        unsubscribe(this._subscription);
18        this._subscription = null;
19    }
20}

Don’t register capture-phase listeners

LWC doesn’t support the capture (see docs) phase of DOM event propagation. Passing { capture: true } to addEventListener has no effect — the listener won’t fire on the way down the tree. There’s no runtime error; the code just behaves as if the option wasn’t set.

Design event handling around the bubbling phase. If you need to intercept an event before child components handle it, restructure the component tree so the intercepting component is a direct parent, or use a declarative handler on the child element in the template.

Conclusion

The patterns in this post share a common root: misunderstanding how data flows through LWC. The wire service is a subscription stream — not a fetch call. @api properties flow down from the parent — they’re not yours to reassign. Events carry immutable snapshots — don’t mutate their payloads or attach shared object references to them.

Correcting your mental model on these three surfaces prevents the large majority of data and communication bugs in LWC development.

In the next and final post in this series, we’ll cover runtime behavior — DOM access, component lifecycle, async patterns, performance, and base component usage.

Resources

About the author

Tim Dionne is a Principal Member of Technical Staff (PMTS) on the Customer Centric Engineering team. He’s worked on many UI features of Salesforce over the years, starting with VisualForce, Aura Components, and Lightning Web Components with an emphasis on Lightning Web Security and Lightning Data Service.

More Blog Posts

React vs. Salesforce: How I Rebuilt My "Vibe-Coded" App on the Platform

React vs. Salesforce: How I Rebuilt My "Vibe-Coded" App on the Platform

Explore how rebuilding a React app using Lightning Web Components (LWC) and Flows reveals the architectural differences between local web development and the Salesforce platform.March 12, 2026

Build Custom Property Editors and Types for Experience Builder

Build Custom Property Editors and Types for Experience Builder

Learn how to enhance the Experience Builder configuration experience by creating custom property editors and complex property types for Lightning Web Components.March 19, 2026

Salesforceのスキルを活用して、Claude Codeで本番運用可能なアプリを構築する

Salesforceのスキルを活用して、Claude Codeで本番運用可能なアプリを構築する

Salesforce Skills を使用して、Claude Code で本番環境向けのアプリを構築し、一括生成されたコード、Apex コントローラーのロジック、およびテストクラスを生成します。August 12, 2026