You've built a dynamic data table in LWC. It pulls records via Apex, responds to a lookup field, and works perfectly on first load. Then a user navigates to a different record, the component re-renders — and the old data is still sitting there. You refresh. It updates. The bug is real but intermittent, and it makes no sense because you're not even using @AuraEnabled(cacheable=true).
The culprit, more often than not, is connectedCallback.
This article walks through a reusable data table architecture I built recently, the cache issue I ran into, and why switching to @api setter methods fixed it.
The Component Architecture
The setup is two components: a parent that holds filter controls (a lookup field, search input, etc.) and a child that renders the table. The child is intentionally generic — swap the lookup reference and it returns different data. You can drop it anywhere.
<!-- Parent: c-record-filter-form -->
<template>
<c-lookup-field onselect={handleLookupSelect}></c-lookup-field>
<c-dynamic-data-table
record-id={recordId}
lookup-id={selectedLookupId}
object-api-name="Account">
</c-dynamic-data-table>
</template>
<!-- Child: c-dynamic-data-table -->
<template>
<template if:true={tableData}>
<lightning-datatable
key-field="Id"
data={tableData}
columns={columns}>
</lightning-datatable>
</template>
</template>
The child receives recordId and lookupId as public properties and is responsible for fetching its own data. Clean separation, reusable anywhere.
Why connectedCallback Seems Like the Right Place to Call Apex
connectedCallback fires when the component is inserted into the DOM. If you need data on load, it feels natural to put your Apex call there.
// c-dynamic-data-table.js
import { LightningElement, api } from 'lwc';
import getTableData from '@salesforce/apex/DataTableController.getTableData';
export default class DynamicDataTable extends LightningElement {
@api recordId;
@api lookupId;
tableData;
columns;
connectedCallback() {
this.fetchData();
}
fetchData() {
getTableData({ recordId: this.recordId, lookupId: this.lookupId })
.then(result => {
this.tableData = result.data;
this.columns = result.columns;
})
.catch(error => console.error(error));
}
}
This works on the first load. The problem surfaces during navigation.
The Stale Data Problem
In Salesforce, when you navigate between records — say from Account A to Account B — LWC components in the record page don't always get destroyed and recreated from scratch. The framework reuses the existing component instance and updates its @api properties.
When that happens, connectedCallback does not fire again. It already ran when the component was first connected to the DOM. So even though recordId is now pointing to Account B, your Apex call never re-runs. The table still shows Account A's data.
This is not a caching issue with @AuraEnabled(cacheable=true). The Apex method isn't even being called. The component is simply reusing its previous state.
User navigates: Account A → Account B
↓
@api recordId updates to Account B's Id
↓
connectedCallback does NOT re-fire
↓
fetchData() never called
↓
Table still shows Account A data
The Fix: @api Setter Methods
The right approach is to trigger fetchData() whenever a property changes, not just when the component first mounts. @api setters handle exactly this.
Instead of:
@api recordId;
@api lookupId;
Use:
_recordId;
_lookupId;
@api
get recordId() {
return this._recordId;
}
set recordId(value) {
this._recordId = value;
this.fetchDataIfReady();
}
@api
get lookupId() {
return this._lookupId;
}
set lookupId(value) {
this._lookupId = value;
this.fetchDataIfReady();
}
fetchDataIfReady() {
if (this._recordId && this._lookupId) {
getTableData({ recordId: this._recordId, lookupId: this._lookupId })
.then(result => {
this.tableData = result.data;
this.columns = result.columns;
})
.catch(error => console.error(error));
}
}
Now every time the parent passes a new recordId or lookupId — whether on first load or after navigation — the setter fires, checks that both values are present, and calls Apex. No stale data, no missed refreshes.
The fetchDataIfReady guard is important. Both setters can fire independently, and you don't want to call Apex with a null lookupId because recordId updated first.
connectedCallback vs @api Setter: When to Use Which
Use connectedCallback when:
- Your component doesn't depend on
@apiproperties to fetch data - You're setting up event listeners, timers, or subscriptions
- Data only needs to load once and won't change based on parent property updates
Use @api setter when:
- The component receives data identifiers as
@apiproperties - The component lives on a record page and navigates between records
- You need the component to react every time a property value changes, not just on mount
One More Thing: Property Initialization Order
When a component first renders, LWC sets @api properties before calling connectedCallback. So if you were relying on connectedCallback to read this.recordId, it would actually be set by that point — which is part of why the bug is subtle. It works on first load, breaks on navigation. That inconsistency is what makes it hard to catch during development.
Setters eliminate this ambiguity entirely. The logic lives where the property lives, and it runs every time — first load or not.
The reusable table pattern itself is solid. The mistake was assuming the component lifecycle on navigation mirrors a fresh page load. Once you account for how LWC handles property updates during navigation, setters are the cleaner and more reliable choice for any component that fetches data based on incoming properties.
Top comments (0)