Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

The short answer: you mutated something instead of replacing it. LWC re-renders when a field’s value is reassigned, and reassignment is not the same as changing what is inside an object or an array. this.rows.push(x) changes nothing the framework can see. this.rows = [...this.rows, x] does.

Replace, do not modifyLWC compares the field, not what is inside it.Re-rendersThe field was reassignedthis.rows = [...this.rows, newRow]this.record = { ...this.record, Name: 'New'}this.count = this.count + 1A getter reading any of the aboveDoes not re-renderThe field was mutated in placethis.rows.push(newRow)this.rows[0].Name = 'New'this.record.Name = 'New'this.rows.sort() — mutates, and sorts inplaceAdd a renderedCallback log: if it fires and the DOM is still wrong, your template reads something else.If it does not fire, you mutated.
The framework compares the field, not what is inside it.

What actually triggers a re-render

Every field on a component is reactive. When you assign a new value to it, the framework compares and re-renders the parts of the template that read it. That comparison is shallow: it looks at the field, not inside it.

So these re-render:

this.count = this.count + 1;
this.rows = [...this.rows, newRow];
this.record = { ...this.record, Name: 'New' };

And these do not:

this.count++;                    // this one does, actually — it reassigns
this.rows.push(newRow);          // mutation, no reassignment
this.rows[0].Name = 'New';       // mutation, deeper
this.record.Name = 'New';        // mutation
this.rows.sort();                // mutation, and it sorts in place

The pattern is one line long: replace, do not modify. Build a new array or object and assign it.

@track, and why you probably do not need it

Before Spring ’20 you needed @track to make fields reactive at all. Now every field is reactive, and @track only adds one thing: it makes changes inside an object or array reactive too.

Reaching for it is usually a sign the mutation habit is still there. Two reasons to prefer replacement anyway: it makes the change obvious in code review, and deep tracking on a large structure costs more than one array copy.

Where @track genuinely helps is a big nested structure you are updating in many places and would rather not clone. Rare, and worth a comment when you do it.

Getters run on every render, which cuts both ways

A getter is recomputed whenever the template re-renders, so derived values stay correct without any tracking:

get openRows() {
    return this.rows.filter((r) => !r.closed);
}

That is the right way to express derived state. It is also a trap for expensive work: a getter that sorts a thousand rows runs again on every unrelated re-render. If it is expensive, compute it when the source changes and store the result.

And a getter reading something that is never reassigned will never look different, however often it runs. Reactivity comes from the assignment, not from the getter.

Lists: the key attribute decides what gets reused

In a for:each or iterator, the key tells the framework which DOM node belongs to which item. Get it wrong and rows appear not to update — the data changed, but the framework reused a node it believed was the same item.

<template for:each={rows} for:item="row">
    <li key={row.Id}>{row.Name}</li>
</template>

Two rules that catch most of it. The key must be unique among siblings and stable across renders, so a record Id is right and an array index is wrong — an index means every row shifts identity when you insert at the top. And the key must be a primitive: an object will not work.

The symptom is distinctive. Delete the middle row and the wrong one seems to vanish, or an input in a list keeps its value while the label above it changes. That is not a reactivity failure. It is the framework reusing exactly what you told it to.

The four causes, in the order I check them

  1. A mutation. push, splice, sort, direct property assignment. Replace instead.
  2. A missing dollar in a wire. { recordId: '$recordId' } re-runs when it changes; { recordId: this.recordId } is read once and never again.
  3. Assigning to a getter’s source that nothing renders. If no template expression reads it, nothing re-renders. Correctly.
  4. Async work resolving after the component is gone, or resolving into a stale copy captured in a closure.

Debugging it in two minutes

Add a renderedCallback() with a log. If it fires and the DOM is still wrong, your data changed and the template is reading something else. Usually a differently-named property. If it does not fire, the framework saw no change, and you are looking at a mutation.

That one test splits the problem in half and takes ten seconds, and it is the same first move as the stale-data-after-save diagnosis, which is faster than reading the component again hoping to spot it.

Frequently asked

Is spreading a big array slow? A shallow copy of a few hundred rows is not your performance problem. Rendering a few hundred rows might be.

Does @track still work? Yes, and it is still the documented way to get deep reactivity. It is just no longer required for basic reactivity.

Why does my child component not update? Same rule one level down: a public property receives a new value only when the parent assigns a new value. Mutating the object the parent passed changes nothing.

Do I need to worry about this in Jest tests? Yes, and helpfully so. A test that asserts on the DOM after a mutation will fail for exactly the reason production does, which is one of the better arguments for writing them.