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
- Verify the component's
apiVersionin<componentName>.js-meta.xml. The reactivity-on-reassignment behavior is API v48+ (Spring '20). Components pinned to v47 or earlier still need@trackon every reactive field. - Check whether reactive state involves Date, Set, or Map. None of them participate in reactivity even with
@track. The fix is always re-create-and-reassign, not "add @track". - Audit
renderedCallback. Setting a reactive field insiderenderedCallbackwithout a guard creates an infinite re-render loop — every render firesrenderedCallback, the new write triggers a re-render, and the hook fires again. - Confirm whether the goal is component-local state (this skill) or cross-component shared state. Cross-component lives elsewhere (
lwc/state-management-with-modules,lwc/message-channel-patterns).
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:
- Date —
this.lastUpdated.setHours(13)does nothing visible. Fix: build a fresh Date and reassign. - Set —
this.tags.add('vip')does nothing visible. Fix:this.tags = new Set([...this.tags, 'vip']); - Map —
this.cache.set(key, value)does nothing visible. Fix:this.cache = new Map([...this.cache, [key, value]]); - 3rd-party class instances with their own internal mutability (Moment.js, RxJS Subjects, MobX observables, custom classes with setters). The framework cannot proxy them.
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.
- The component is pinned to API v47 or earlier and cannot move.
- A third-party library (or nested form pattern) requires in-place mutation and cannot be refactored.
- 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
| Situation | Recommended Approach | Reason |
|---|---|---|
| Update a primitive field | Plain reassignment | Reactive since Spring '20 |
| Update a property of an object field | Spread + reassign | Avoid @track; cleanest |
| Append/remove from an array | Spread + reassign | Same reason |
| Deeply nested form state with many bindings | @track on the root field | Readability beats @track cleanliness |
| Date / Set / Map field | Re-create + reassign | Decorator does not help |
Read DOM in renderedCallback | Guarded with _hasRenderedOnce | Prevent infinite loop |
| Expensive getter in template | Cache via setter | Getters recompute every render |
| API version v47 or earlier | @track everywhere | Reactivity rules differ pre-v48 |
Recommended Workflow
- Read the component's API version in
js-meta.xml. If < 48, the rules below do not apply — use@trackon everything reactive. - List the reactive fields and classify each: primitive, object, array, Date/Set/Map, or external class instance.
- Replace in-place mutations with spread-and-reassign for object/array fields. Drop
@trackfrom those fields if it was added defensively. - For Date/Set/Map fields, replace
.setX(...),.add(...),.set(k,v)with re-create-and-reassign. - Audit every
renderedCallbackfor unguarded writes to reactive fields. Add the_hasRenderedOnceguard or compare-then-set. - Profile expensive getters used in the template. Cache via setter where the input changes far less often than the render frequency.
- Re-test with the existing Jest specs; reactivity changes are the most common silent regression.
Review Checklist
- [ ] All
@trackdecorators on the component are justified by one of the three Pattern B cases (legacy API, third-party constraint, deeply nested form). - [ ] No in-place mutation of object properties or array elements outside
@track-decorated fields. - [ ] No
.setX()/.add()/.set(k,v)calls on Date / Set / Map fields without a follow-up reassignment. - [ ] Every
renderedCallbackeither does not write reactive fields, or has a one-time-guard or compare-then-set pattern. - [ ] Expensive getters used in the template are cached or moved into setter-based backing fields.
- [ ] Component's
apiVersionis set explicitly injs-meta.xml(do not rely on the org-default). - [ ] Cross-component shared state is not faked with reactive class fields — use Lightning Message Service, a custom event, or a shared module instead.
Salesforce-Specific Gotchas
@trackplus@apion the same field is not supported. The compiler accepts the syntax but the reactivity behavior is undefined. Pick one: a public input field is@apionly; reactivity on internal state belongs in a separate field.- Reactive proxies break
instanceofchecks.this.someClassInstance instanceof MyClassmay returnfalsewhen the field is wrapped by the reactive proxy. Avoidinstanceofon reactive-tracked references; tag the type with a string field instead. - 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@trackandpush, or move the data behind an@wireadapter that does paging. structuredClonedoes 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.- 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(...)). renderedCallbackfires on every prop change, not just initial render. Aura'safterRenderfired only once; LWC's hook is more aggressive. Migrating Aura code that initializes a chart library insideafterRenderwill create duplicate charts in LWC.
Output Artifacts
| Artifact | Description |
|---|---|
| Reactivity audit | Per-field classification (primitive / object / array / Date-Set-Map / external) and the chosen update pattern |
| Refactor plan | Specific lines/files to change to remove unnecessary @track and replace in-place mutations |
renderedCallback guard checklist | Each renderedCallback in the component, marked safe / needs-guard / needs-compare-then-set |
Related Skills
lwc/wire-adapters—@wirereactive parameters (different surface, related concept).lwc/ldws-and-uirecordapi— Lightning Data Service caching; what NOT to put in component-local state.lwc/state-management-with-modules— cross-component shared state via shared ES modules.lwc/message-channel-patterns— cross-component event-based state via LMS.lwc/common-lwc-runtime-errors— sibling skill for the symptom-based debugging cousin of these issues.lwc/aura-to-lwc-migration— explains whyrenderedCallbackfires more aggressively than Aura'safterRender.