Working with Salesforce Data · Lesson 9 of 25
Reactivity and Immutable Data in LWC
"I changed the value, but the screen didn't update." "Cannot assign to read only property." "console.log shows a Proxy." These three complaints come from the same place: how LWC watches your data. Once you understand the rules, they stop being mysterious – and the fix is almost always the same habit: create new objects and arrays instead of changing existing ones.
In this lesson
- What makes a component re-render
- When @track is still useful
- Why @wire and @api data is read-only
- Copying data safely – and the structuredClone trap
Rule 1: assigning a field re-renders
Every class field is reactive. When you assign a new value to a field that the template (or a getter it uses) reads, LWC re-renders. But without @track, LWC only watches the field itself – not what happens inside an object or array stored in it. Let's prove it:
<!-- taskList.html -->
<template>
<p>Tasks: {tasks.length} · {taskNames}</p>
<lightning-button label="Push (mutate)" onclick={addByPush}></lightning-button>
<lightning-button label="Spread (new array)" onclick={addBySpread}></lightning-button>
</template>
// taskList.js
import { LightningElement } from 'lwc';
export default class TaskList extends LightningElement {
tasks = ['Call Acme'];
get taskNames() {
return this.tasks.join(', ');
}
addByPush() {
this.tasks.push('Send quote'); // changes the array, field still points to it
}
addBySpread() {
this.tasks = [...this.tasks, 'Book demo']; // new array assigned → re-render
}
}
Tasks: 1 · Call Acme [Push (mutate)] [Spread (new array)] › click “Push (mutate)” Tasks: 1 · Call Acme [Push (mutate)] [Spread (new array)] › click “Spread (new array)” Tasks: 3 · Call Acme, Send quote, Book demo [Push (mutate)] [Spread (new array)]
After "Push" the screen still says one task – even though the array really does contain two. The next render (triggered by the spread) shows everything, including the pushed item. That delayed, inconsistent update is exactly the kind of bug that makes people distrust their code.
Rule 2: @track watches inside objects and arrays
Decorating the field with @track tells LWC to observe changes deep inside plain objects and arrays, so push and this.form.Name = … re-render too:
// trackedList.js
import { LightningElement, track } from 'lwc';
export default class TrackedList extends LightningElement {
@track tasks = ['Call Acme'];
get taskNames() {
return this.tasks.join(', ');
}
addByPush() {
this.tasks.push('Send quote'); // tracked: this re-renders
}
}
<!-- trackedList.html -->
<template>
<p>Tasks: {tasks.length} · {taskNames}</p>
<lightning-button label="Push (mutate)" onclick={addByPush}></lightning-button>
</template>
Tasks: 1 · Call Acme [Push (mutate)] › click “Push (mutate)” Tasks: 2 · Call Acme, Send quote [Push (mutate)]
So which should you use? Most teams prefer immutable updates (spread, map, filter) and save @track for complex nested state like a multi-level form. Immutable updates also work with read-only data, which @track can't help with. @track only observes plain objects and arrays – not Map, Set or Date instances.
Will this re-render?
- this.count = 5yes – field assigned
- this.list = [...this.list, x]yes – new array assigned
- this.list.push(x)only with @track
- this.record.Name = 'x'only with @track (and never on wire or @api data)
Rule 3: data you receive is read-only
Two kinds of data don't belong to your component:
- @wire results come from the Lightning Data Service cache and are shared with every other component that asked for the same data. They're frozen so one component can't corrupt another's view.
- @api values belong to the parent. LWC hands the child a read-only proxy so the child can't change the parent's data behind its back.
// contactEditor.js
import { LightningElement, api } from 'lwc';
export default class ContactEditor extends LightningElement {
@api contact;
message = 'Ready';
renameDirectly() {
try {
this.contact.LastName = 'Sharma'; // ✗ mutating the parent's object
} catch (e) {
this.message = e.message;
}
}
renameOnCopy() {
const draft = { ...this.contact, LastName: 'Sharma' }; // ✓ a copy we own
this.message = `Draft: ${draft.FirstName} ${draft.LastName} (original still ${this.contact.LastName})`;
}
}
<!-- contactEditor.html -->
<template>
<lightning-button label="Rename directly" onclick={renameDirectly}></lightning-button>
<lightning-button label="Rename a copy" onclick={renameOnCopy}></lightning-button>
<p>{message}</p>
</template>
[Rename directly] [Rename a copy] Ready › click “Rename directly” [Rename directly] [Rename a copy] 'set' on proxy: trap returned falsish for property 'LastName' › click “Rename a copy” [Rename directly] [Rename a copy] Draft: Neha Sharma (original still Rao)
The odd-looking message – 'set' on proxy: trap returned falsish – is how the browser reports a write that LWC's read-only proxy refused. In debug mode LWC also logs a clearer "Invalid mutation" error naming the property. Without the try/catch, the handler would simply stop at that line.
If the child genuinely needs to change the value, it either works on a copy (as above) or sends an event asking the parent to make the change – the "data down, events up" pattern you'll meet in the events lesson.
Copying data safely
// LWC wraps received data in Proxy objects; here we simulate one
const wireData = new Proxy({ Name: 'Acme', CreatedDate: '2026-11-02T10:15:00.000Z', Owner: { Name: 'Asha' } }, {});
const shallow = { ...wireData }; // fine for flat changes
shallow.Name = 'Acme Ltd';
console.log(shallow.Name, shallow.Owner === wireData.Owner);
const deep = JSON.parse(JSON.stringify(wireData)); // deep copy; works on proxies
deep.Owner.Name = 'Ravi';
console.log(deep.Owner.Name, wireData.Owner.Name);
try {
structuredClone(wireData); // the modern deep copy…
} catch (e) {
console.log(`structuredClone: ${e.name}`); // …can't clone a Proxy
}
// JSON round-trips lose anything that isn't plain JSON
console.log(JSON.parse(JSON.stringify({ when: new Date(0), missing: undefined, fn() {} })));
Acme Ltd true
Ravi Asha
structuredClone: DataCloneError
{ when: '1970-01-01T00:00:00.000Z' }
| Technique | Depth | Watch out for |
|---|---|---|
{ ...obj }, [...arr], map | shallow | nested objects are still shared (and read-only) |
JSON.parse(JSON.stringify(x)) | deep | Dates become strings; undefined and functions disappear |
structuredClone(x) | deep | throws on LWC proxies – use it only on data you built yourself |
Seeing a Proxy in the console?
That's LWC's wrapper, not a bug. To read the actual values while debugging, log JSON.stringify(this.records, null, 2), or enable Custom Formatters in Chrome DevTools, which LWC uses to display proxies nicely in debug mode.
Practice
Try it yourself
Wired opportunities should get an
isLargeflag before going to a datatable. A colleague writesdata.forEach((o) => { o.isLarge = o.Amount > 100000; })and gets an error. Rewrite it.Show answer
this.rows = data.map((o) => ({ ...o, isLarge: o.Amount > 100000 }));– build new objects instead of mutating the read-only cache.Mark one task as done in
this.tasks(an array of{ id, done }objects) immutably.Show answer
this.tasks = this.tasks.map((t) => (t.id === id ? { ...t, done: true } : t));
Quick check
Does this.list.push(x) re-render without @track?
No – the field still points to the same array.
Why is wire data read-only?
It comes from a shared cache used by other components.
Why can structuredClone fail on LWC data?
LWC wraps data in Proxy objects, which structuredClone can't clone.
Summary
- Assigning a field re-renders; mutating inside it needs @track.
- Prefer immutable updates with spread, map and filter.
- @wire and @api data is read-only – copy it before you change it.
- Use spread for shallow copies and JSON round-trips for deep copies of LWC data.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.