LearnFrenzy
Sign in Take a free test

Working with Salesforce Data · Lesson 9 of 25

Reactivity and Immutable Data in LWC

6 min read Updated Nov 8, 2026 Saurabh Samir

"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
    }
}

Result

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>

Result

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?

  1. this.count = 5yes – field assigned
  2. this.list = [...this.list, x]yes – new array assigned
  3. this.list.push(x)only with @track
  4. 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>

Result

[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() {} })));

Output

Acme Ltd true
Ravi Asha
structuredClone: DataCloneError
{ when: '1970-01-01T00:00:00.000Z' }
TechniqueDepthWatch out for
{ ...obj }, [...arr], mapshallownested objects are still shared (and read-only)
JSON.parse(JSON.stringify(x))deepDates become strings; undefined and functions disappear
structuredClone(x)deepthrows 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

  1. Wired opportunities should get an isLarge flag before going to a datatable. A colleague writes data.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.

  2. 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.

Next lesson

Search LearnFrenzy

Esc

Quick links