SfSkills Domains Docs GitHub

Home / lwc / lwc-reactive-state-patterns

lwc-reactive-state-patterns

LWC reactivity after API v48 — @track vs class fields, deep object updates, rerender pitfalls. Triggers: LWC reactivity, @track, reactive object, rerender. NOT for wire adapter refresh — use lwc/lwc-wire-refresh-patterns. NOT for record form data — use lwc/lwc-lightning-record-forms.

Open the skill folder on GitHub


LWC Reactive State Patterns

LWC reactivity has had two eras. Before Spring '20 (API v48), every reactive field needed @track. Since Spring '20, all class fields are reactive on reassignment — @track is no longer needed for primitives or for fields that are reassigned. But @track is still required for deep observation of plain object properties and array elements when the field is mutated in place. And no decorator at all makes Date, Set, or Map reactive — those need a re-create-and-reassign discipline.

This skill teaches the contract: when reassignment is enough, when @track is genuinely needed, when neither works (Date/Set/Map), and how to avoid the canonical renderedCallback infinite-loop trap. It does not cover @wire reactive parameters or Lightning Data Service — those are separate reactivity surfaces handled by lwc/wire-adapters and lwc/ldws-and-uirecordapi.


Before Starting


Core Concepts

1. The post–Spring '20 reactivity contract

Every class field declared on a component (without any decorator) is reactive when reassigned. The template re-renders when the right-hand side of an assignment to a referenced field changes:

// Reactive: reassignment triggers rerender
this.count = this.count + 1;
this.user = { ...this.user, name: 'Ada' };
this.items = [...this.items, newItem];

No @track needed. The runtime detects the assignment via the underlying reactive proxy and schedules a re-render.

2. When @track is still required

@track enables deep observation of object properties and array elements. Without @track, mutating an object's property in place does not trigger a re-render — even though the field is "reactive":

// NOT reactive without @track
this.user.name = 'Ada';        // template does NOT rerender
this.items[0].selected = true; // template does NOT rerender
this.items.push(newItem);      // template does NOT rerender

// With @track on user and items, all three become reactive
@track user = { name: '' };
@track items = [];

The simple rule: if you reassign the field reference, you do not need @track. If you mutate its contents in place, you do. Reassignment is the recommended pattern (it composes with immutable-update libraries and plays nicely with redux-style reducers); @track is the legacy escape hatch.

3. What reactivity does NOT cover

The reactive proxy only tracks plain objects and arrays. The following are silently NOT observed even with @track:

3b. The read-set rule — the clause that explains what's left

Even after §1–§3 are applied correctly, one rule remains and it is the usual cause of "I reassigned and still nothing happened":

A component rerenders only if a property accessed during the previous rendering cycle is updated, even when the object is annotated with @track.

A property that was not read during the last render is not in that cycle's read-set, so mutating it schedules nothing. The two ways this happens in practice — an early return in a getter that short-circuits before touching the field, and a field first referenced inside an lwc:if branch that was false last render — are worked through with diagnostics in references/gotchas.md §7. Read it before reaching for any "force a re-render" workaround.

4. The renderedCallback infinite-loop trap

renderedCallback fires after every render. Writing a reactive property inside it without a guard creates an infinite loop:

renderedCallback() {
  this.measuredHeight = this.template.querySelector('.box').offsetHeight;
  // BUG: assignment triggers rerender → renderedCallback fires again
}

Two correct patterns:

// (a) Guard with a hasRendered flag (most common)
renderedCallback() {
  if (this._hasRenderedOnce) return;
  this._hasRenderedOnce = true;
  this.measuredHeight = this.template.querySelector('.box').offsetHeight;
}

// (b) Compare-then-set (when you do need to react to layout changes)
renderedCallback() {
  const height = this.template.querySelector('.box').offsetHeight;
  if (this.measuredHeight !== height) {
    this.measuredHeight = height;
  }
}

The second is needed when the component must rerender on real layout changes (e.g., responsive containers). The first is right when the write is one-time setup.

5. Reactive getter caching rules

Getters that derive state from reactive fields recompute on every access during a render. They are not memoized by the framework. If a getter is expensive, cache via a setter on the source field:

// Recomputed on every render — only cheap getters belong here
get filteredItems() {
  return this.items.filter(i => i.selected);
}

// For expensive derivations: compute once per write
set items(value) {
  this._items = value;
  this._filteredItems = value.filter(i => i.selected);
}
get items() { return this._items; }
get filteredItems() { return this._filteredItems; }

Common Patterns

Pattern A — Reassignment over in-place mutation (the default)

When to use: Any time you would write this.x.y = z or this.arr.push(x). This is the modern, decorator-free path.

How it works:

// Object update
this.user = { ...this.user, name: 'Ada' };

// Array append
this.items = [...this.items, newItem];

// Array remove by id
this.items = this.items.filter(i => i.id !== removedId);

// Nested update — use spread chains, NOT mutation
this.form = {
  ...this.form,
  address: { ...this.form.address, city: 'Boston' },
};

Why not the alternative: In-place mutation requires @track, inhibits structural sharing, and breaks if the value is later passed to a child as @api (children should not see "the same reference, but different contents now"). Reassignment communicates intent — "this is a new value" — to both the framework and the team.

Pattern B — @track only for legacy edges or genuine in-place needs

When to use: Three real cases.

  1. The component is pinned to API v47 or earlier and cannot move.
  2. A third-party library (or nested form pattern) requires in-place mutation and cannot be refactored.
  3. A bound input on a deeply nested object literal needs to update in place without spreading every parent — common in form-heavy code where readability of nested updates is the constraint.

How it works:

import { LightningElement, track } from 'lwc';
export default class FormComponent extends LightningElement {
  @track form = {
    address: { city: '', zip: '' },
    contact: { email: '', phone: '' },
  };

  handleChange(event) {
    // In-place mutation IS reactive because of @track
    this.form.address.city = event.target.value;
  }
}

Why not the alternative: Outside the three cases above, @track is noise. It is not "safer" — adding @track to every field is a smell that reads as "I do not understand the reactivity rules", and it can mask the Date/Set/Map issue (the developer assumes @track covers "everything", which it does not).

Pattern C — Guarded renderedCallback

When to use: Anytime renderedCallback measures the DOM, sets up a third-party library, or writes to a reactive field.

How it works: Use the _hasRenderedOnce guard for one-time setup; use the compare-then-set pattern for layout-driven re-renders. Never write a reactive field unconditionally.


Decision Guidance

SituationRecommended ApproachReason
Update a primitive fieldPlain reassignmentReactive since Spring '20
Update a property of an object fieldSpread + reassignAvoid @track; cleanest
Append/remove from an arraySpread + reassignSame reason
Deeply nested form state with many bindings@track on the root fieldReadability beats @track cleanliness
Date / Set / Map fieldRe-create + reassignDecorator does not help
Read DOM in renderedCallbackGuarded with _hasRenderedOncePrevent infinite loop
Expensive getter in templateCache via setterGetters recompute every render
API version v47 or earlier@track everywhereReactivity rules differ pre-v48

  1. Read the component's API version in js-meta.xml. If < 48, the rules below do not apply — use @track on everything reactive.
  2. List the reactive fields and classify each: primitive, object, array, Date/Set/Map, or external class instance.
  3. Replace in-place mutations with spread-and-reassign for object/array fields. Drop @track from those fields if it was added defensively.
  4. For Date/Set/Map fields, replace .setX(...), .add(...), .set(k,v) with re-create-and-reassign.
  5. Audit every renderedCallback for unguarded writes to reactive fields. Add the _hasRenderedOnce guard or compare-then-set.
  6. Profile expensive getters used in the template. Cache via setter where the input changes far less often than the render frequency.
  7. Re-test with the existing Jest specs; reactivity changes are the most common silent regression.

Review Checklist


Salesforce-Specific Gotchas

  1. @track plus @api on the same field is not supported. The compiler accepts the syntax but the reactivity behavior is undefined. Pick one: a public input field is @api only; reactivity on internal state belongs in a separate field.
  2. Reactive proxies break instanceof checks. this.someClassInstance instanceof MyClass may return false when the field is wrapped by the reactive proxy. Avoid instanceof on reactive-tracked references; tag the type with a string field instead.
  3. Spread-and-reassign on a 100k-item array is not free. this.items = [...this.items, newItem] is O(n) per append. For genuinely large arrays, accept @track and push, or move the data behind an @wire adapter that does paging.
  4. structuredClone does not preserve reactivity. A deep-cloned reactive object is no longer proxied. This rarely matters but bites if you clone, mutate the clone in place, then assign back — the assignment IS reactive, but intermediate steps are not what they look like.
  5. A property not read during the previous render will not trigger a re-render when it changes — not a bug, the documented read-set rule (Core Concepts §3b, gotchas §7). Never "fix" it with a dummy-boolean toggle or JSON.parse(JSON.stringify(...)).
  6. renderedCallback fires on every prop change, not just initial render. Aura's afterRender fired only once; LWC's hook is more aggressive. Migrating Aura code that initializes a chart library inside afterRender will create duplicate charts in LWC.

Output Artifacts

ArtifactDescription
Reactivity auditPer-field classification (primitive / object / array / Date-Set-Map / external) and the chosen update pattern
Refactor planSpecific lines/files to change to remove unnecessary @track and replace in-place mutations
renderedCallback guard checklistEach renderedCallback in the component, marked safe / needs-guard / needs-compare-then-set

Package files