fe-lwc-skill 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/bin/index.js +145 -0
- package/package.json +23 -0
- package/skill/SKILL.md +88 -0
- package/skill/rules/data-error-handling.md +297 -0
- package/skill/rules/data-lds-first.md +261 -0
- package/skill/rules/data-wire-vs-imperative.md +248 -0
- package/skill/rules/design-token-dxp.md +230 -0
- package/skill/rules/design-token-slds.md +258 -0
- package/skill/rules/events-custom-event.md +227 -0
- package/skill/rules/events-lms.md +193 -0
- package/skill/rules/template-directives.md +191 -0
- package/skill/rules/template-dom-querying.md +202 -0
- package/skill/rules/template-form-pattern.md +285 -0
- package/skill/rules/template-track-immutable.md +206 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# template-dom-querying
|
|
2
|
+
|
|
3
|
+
**Impact: HIGH**
|
|
4
|
+
|
|
5
|
+
DOM querying in LWC is scoped to `this.template`. You cannot reach into a child component's DOM from a parent — attempting to do so always returns `null`.
|
|
6
|
+
|
|
7
|
+
> **Rule:**
|
|
8
|
+
>
|
|
9
|
+
> - Always use `data-id` attributes as query selectors — never class names or tag names.
|
|
10
|
+
> - `this.template.querySelector` is safe inside event handlers and `renderedCallback` — **not** in `connectedCallback`.
|
|
11
|
+
> - Never query DOM across component boundaries. Expose `@api` methods on children instead.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Case 1: Querying across component boundaries
|
|
16
|
+
|
|
17
|
+
**Incorrect — parent queries into child's shadow DOM (always returns null):**
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// patientOnboarding.ts
|
|
21
|
+
import { LightningElement } from "lwc";
|
|
22
|
+
|
|
23
|
+
// @ts-ignore
|
|
24
|
+
export default class PatientOnboarding extends LightningElement {
|
|
25
|
+
currentStep: number = 1;
|
|
26
|
+
|
|
27
|
+
public handleNext(): void {
|
|
28
|
+
// ❌ Always returns null — shadow DOM blocks cross-component queries
|
|
29
|
+
const input = this.template.querySelector("c-patient-onboarding-step1 lightning-input");
|
|
30
|
+
if (!input) return;
|
|
31
|
+
this.currentStep = 2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Correct — parent calls `@api` method on child, child validates its own DOM:**
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// patientOnboardingStep1.ts
|
|
40
|
+
import { LightningElement, api } from "lwc";
|
|
41
|
+
|
|
42
|
+
// @ts-ignore
|
|
43
|
+
export default class PatientOnboardingStep1 extends LightningElement {
|
|
44
|
+
// @ts-ignore
|
|
45
|
+
@api
|
|
46
|
+
public validate(): boolean {
|
|
47
|
+
const inputs = this.template.querySelectorAll<HTMLInputElement>("lightning-input");
|
|
48
|
+
return [...inputs].reduce((isValid, input) => {
|
|
49
|
+
input.reportValidity();
|
|
50
|
+
return isValid && input.checkValidity();
|
|
51
|
+
}, true);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// patientOnboarding.ts
|
|
58
|
+
import { LightningElement } from "lwc";
|
|
59
|
+
|
|
60
|
+
interface StepComponent extends HTMLElement {
|
|
61
|
+
validate(): boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// @ts-ignore
|
|
65
|
+
export default class PatientOnboarding extends LightningElement {
|
|
66
|
+
currentStep: number = 1;
|
|
67
|
+
|
|
68
|
+
public get isStep1(): boolean {
|
|
69
|
+
return this.currentStep === 1;
|
|
70
|
+
}
|
|
71
|
+
public get isStep2(): boolean {
|
|
72
|
+
return this.currentStep === 2;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public handleNext(): void {
|
|
76
|
+
const stepComponent = this.template.querySelector<StepComponent>('[data-id="current-step"]');
|
|
77
|
+
if (!stepComponent) return;
|
|
78
|
+
|
|
79
|
+
// ✅ Child validates itself — parent only asks for the result
|
|
80
|
+
if (!stepComponent.validate()) return;
|
|
81
|
+
|
|
82
|
+
this.currentStep += 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public handlePrev(): void {
|
|
86
|
+
this.currentStep -= 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<!-- patientOnboarding.html -->
|
|
93
|
+
<template>
|
|
94
|
+
<template lwc:if="{isStep1}">
|
|
95
|
+
<c-patient-onboarding-step1 data-id="current-step"></c-patient-onboarding-step1>
|
|
96
|
+
</template>
|
|
97
|
+
<template lwc:if="{isStep2}">
|
|
98
|
+
<c-patient-onboarding-step2 data-id="current-step"></c-patient-onboarding-step2>
|
|
99
|
+
</template>
|
|
100
|
+
<div>
|
|
101
|
+
<lightning-button label="Back" onclick="{handlePrev}"></lightning-button>
|
|
102
|
+
<lightning-button variant="brand" label="Next" onclick="{handleNext}"></lightning-button>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
> `data-id="current-step"` stays the same across all steps — parent always queries the same selector.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Case 2: Class/tag selectors instead of `data-id`
|
|
112
|
+
|
|
113
|
+
**Incorrect — fragile selectors tied to implementation details:**
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// bookingForm.ts
|
|
117
|
+
handleSubmit(event: Event): void {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
// ❌ Breaks if class name changes or element type is swapped
|
|
120
|
+
const input = this.template.querySelector<HTMLInputElement>('.date-input');
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Correct — stable `data-id` selectors:**
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
// bookingForm.ts
|
|
128
|
+
import { LightningElement } from "lwc";
|
|
129
|
+
|
|
130
|
+
// @ts-ignore
|
|
131
|
+
export default class BookingForm extends LightningElement {
|
|
132
|
+
public handleSubmit(event: Event): void {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
|
|
135
|
+
const dateInput = this.template.querySelector<HTMLInputElement>('[data-id="date-input"]');
|
|
136
|
+
const timeInput = this.template.querySelector<HTMLInputElement>('[data-id="time-input"]');
|
|
137
|
+
|
|
138
|
+
if (!dateInput || !timeInput) return;
|
|
139
|
+
|
|
140
|
+
if (!dateInput.checkValidity() || !timeInput.checkValidity()) {
|
|
141
|
+
dateInput.reportValidity();
|
|
142
|
+
timeInput.reportValidity();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.submitBooking();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private submitBooking(): void {
|
|
150
|
+
// submit logic
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<!-- bookingForm.html -->
|
|
157
|
+
<template>
|
|
158
|
+
<form onsubmit="{handleSubmit}">
|
|
159
|
+
<lightning-input data-id="date-input" name="date" type="date" label="Appointment Date" required></lightning-input>
|
|
160
|
+
<lightning-input data-id="time-input" name="time" type="time" label="Appointment Time" required></lightning-input>
|
|
161
|
+
<lightning-button type="submit" variant="brand" label="Book"></lightning-button>
|
|
162
|
+
</form>
|
|
163
|
+
</template>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Case 3: Querying multiple elements with `querySelectorAll`
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// patientOnboardingStep2.ts
|
|
172
|
+
import { LightningElement, api } from "lwc";
|
|
173
|
+
|
|
174
|
+
// @ts-ignore
|
|
175
|
+
export default class PatientOnboardingStep2 extends LightningElement {
|
|
176
|
+
// @ts-ignore
|
|
177
|
+
@api
|
|
178
|
+
public validate(): boolean {
|
|
179
|
+
const allInputs = this.template.querySelectorAll<HTMLInputElement>("lightning-input, lightning-textarea");
|
|
180
|
+
return [...allInputs].reduce((isValid, input) => {
|
|
181
|
+
input.reportValidity();
|
|
182
|
+
return isValid && input.checkValidity();
|
|
183
|
+
}, true);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Quick Decision Guide
|
|
191
|
+
|
|
192
|
+
| Situation | Pattern |
|
|
193
|
+
| -------------------------------------- | ------------------------------------------------------------ |
|
|
194
|
+
| Query element in the same component | `this.template.querySelector<T>('[data-id="x"]')` |
|
|
195
|
+
| Safe timing for querySelector | Event handlers, `renderedCallback` — not `connectedCallback` |
|
|
196
|
+
| Trigger behavior in a child | Expose `@api` method on child, call from parent |
|
|
197
|
+
| Validate all inputs in a form | `querySelectorAll('lightning-input')` + `reduce` |
|
|
198
|
+
| Query child's internal DOM from parent | ❌ Not possible — Shadow DOM blocks it |
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
**Reference:** [Access Elements the Component Owns — Salesforce LWC Developer Guide](https://developer.salesforce.com/docs/platform/lwc/guide/create-components-dom-work.html)
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# template-form-pattern
|
|
2
|
+
|
|
3
|
+
**Impact: HIGH**
|
|
4
|
+
|
|
5
|
+
LWC provides `lightning-record-edit-form` for standard CRUD forms. Only build a custom form when the UX requirement goes beyond what the base component supports.
|
|
6
|
+
|
|
7
|
+
> **Rule:**
|
|
8
|
+
>
|
|
9
|
+
> - Use `lightning-record-edit-form` for simple create/edit of a single record.
|
|
10
|
+
> - In custom forms, use a single `handleChange` keyed on `event.target.name` — never per-field handlers.
|
|
11
|
+
> - Keep all form state in one typed interface.
|
|
12
|
+
> - In multi-step wizards, the parent owns state and submits once at the final step. Each step exposes `validate()` and `getFormData()` as `@api` methods.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## When to use `lightning-record-edit-form`
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<!-- appointmentEditForm.html — no JS needed -->
|
|
20
|
+
<template>
|
|
21
|
+
<lightning-record-edit-form record-id="{recordId}" object-api-name="Appointment__c">
|
|
22
|
+
<lightning-messages></lightning-messages>
|
|
23
|
+
<lightning-input-field field-name="Name"></lightning-input-field>
|
|
24
|
+
<lightning-input-field field-name="Date__c"></lightning-input-field>
|
|
25
|
+
<lightning-input-field field-name="Status__c"></lightning-input-field>
|
|
26
|
+
<lightning-button type="submit" variant="brand" label="Save"></lightning-button>
|
|
27
|
+
</lightning-record-edit-form>
|
|
28
|
+
</template>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## When to use a custom form
|
|
34
|
+
|
|
35
|
+
| Situation | Reason |
|
|
36
|
+
| ------------------------------------ | ---------------------------------------------- |
|
|
37
|
+
| Multi-step wizard | `lightning-record-edit-form` cannot span steps |
|
|
38
|
+
| Conditional fields | Requires reactive state control |
|
|
39
|
+
| Data from multiple objects | LDS handles one object at a time |
|
|
40
|
+
| Custom validation beyond field-level | Needs manual `checkValidity` |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Case 1: Conditional fields — single `handleChange`, centralized state
|
|
45
|
+
|
|
46
|
+
**Incorrect — per-field handlers, scattered booleans:**
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// referralForm.ts
|
|
50
|
+
import { LightningElement } from "lwc";
|
|
51
|
+
|
|
52
|
+
// @ts-ignore
|
|
53
|
+
export default class ReferralForm extends LightningElement {
|
|
54
|
+
referralType: string = "";
|
|
55
|
+
specialistName: string = "";
|
|
56
|
+
clinicName: string = "";
|
|
57
|
+
showSpecialistFields: boolean = false;
|
|
58
|
+
|
|
59
|
+
// ❌ Grows linearly with form size
|
|
60
|
+
public handleTypeChange(event: Event): void {
|
|
61
|
+
this.referralType = (event.target as HTMLInputElement).value;
|
|
62
|
+
this.showSpecialistFields = this.referralType === "specialist";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public handleSpecialistChange(event: Event): void {
|
|
66
|
+
this.specialistName = (event.target as HTMLInputElement).value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Correct — single `handleChange`, derived conditional from state:**
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// referralForm.ts
|
|
75
|
+
import { LightningElement } from "lwc";
|
|
76
|
+
|
|
77
|
+
interface ReferralFormState {
|
|
78
|
+
referralType: string;
|
|
79
|
+
specialistName: string;
|
|
80
|
+
clinicName: string;
|
|
81
|
+
notes: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// @ts-ignore
|
|
85
|
+
export default class ReferralForm extends LightningElement {
|
|
86
|
+
formState: ReferralFormState = {
|
|
87
|
+
referralType: "",
|
|
88
|
+
specialistName: "",
|
|
89
|
+
clinicName: "",
|
|
90
|
+
notes: "",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ✅ One handler for all fields
|
|
94
|
+
public handleChange(event: Event): void {
|
|
95
|
+
const target = event.target as HTMLInputElement;
|
|
96
|
+
this.formState = { ...this.formState, [target.name]: target.value };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ✅ Derived from state — no extra boolean needed
|
|
100
|
+
public get showSpecialistFields(): boolean {
|
|
101
|
+
return this.formState.referralType === "specialist";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public handleSubmit(event: Event): void {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
const allInputs = this.template.querySelectorAll<HTMLInputElement>("lightning-input, lightning-combobox");
|
|
107
|
+
const isValid = [...allInputs].reduce((valid, input) => {
|
|
108
|
+
input.reportValidity();
|
|
109
|
+
return valid && input.checkValidity();
|
|
110
|
+
}, true);
|
|
111
|
+
|
|
112
|
+
if (!isValid) return;
|
|
113
|
+
this.submitForm();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private submitForm(): void {
|
|
117
|
+
// submit logic
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Case 2: Multi-step wizard — parent owns state, submits once at final step
|
|
125
|
+
|
|
126
|
+
**Step component — renders fields, exposes `validate()` and `getFormData()`:**
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// onboardingStep1.ts
|
|
130
|
+
import { LightningElement, api } from "lwc";
|
|
131
|
+
|
|
132
|
+
interface Step1FormState {
|
|
133
|
+
firstName: string;
|
|
134
|
+
lastName: string;
|
|
135
|
+
email: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// @ts-ignore
|
|
139
|
+
export default class OnboardingStep1 extends LightningElement {
|
|
140
|
+
formState: Step1FormState = {
|
|
141
|
+
firstName: "",
|
|
142
|
+
lastName: "",
|
|
143
|
+
email: "",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
public handleChange(event: Event): void {
|
|
147
|
+
const target = event.target as HTMLInputElement;
|
|
148
|
+
this.formState = { ...this.formState, [target.name]: target.value };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// @ts-ignore
|
|
152
|
+
@api
|
|
153
|
+
public validate(): boolean {
|
|
154
|
+
const inputs = this.template.querySelectorAll<HTMLInputElement>("lightning-input");
|
|
155
|
+
return [...inputs].reduce((valid, input) => {
|
|
156
|
+
input.reportValidity();
|
|
157
|
+
return valid && input.checkValidity();
|
|
158
|
+
}, true);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// @ts-ignore
|
|
162
|
+
@api
|
|
163
|
+
public getFormData(): Step1FormState {
|
|
164
|
+
return { ...this.formState };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Parent — accumulates state, submits once at final step:**
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// onboardingWizard.ts
|
|
173
|
+
import { LightningElement } from "lwc";
|
|
174
|
+
import { createRecord } from "lightning/uiRecordApi";
|
|
175
|
+
import { ShowToastEvent } from "lightning/platformShowToastEvent";
|
|
176
|
+
import { reduceErrors } from "c/utils";
|
|
177
|
+
import CONTACT_OBJECT from "@salesforce/schema/Contact";
|
|
178
|
+
|
|
179
|
+
interface StepComponent extends HTMLElement {
|
|
180
|
+
validate(): boolean;
|
|
181
|
+
getFormData(): Record<string, string>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// @ts-ignore
|
|
185
|
+
export default class OnboardingWizard extends LightningElement {
|
|
186
|
+
currentStep: number = 1;
|
|
187
|
+
totalSteps: number = 3;
|
|
188
|
+
formData: Record<string, string> = {};
|
|
189
|
+
|
|
190
|
+
public get isLastStep(): boolean {
|
|
191
|
+
return this.currentStep === this.totalSteps;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public get isStep1(): boolean {
|
|
195
|
+
return this.currentStep === 1;
|
|
196
|
+
}
|
|
197
|
+
public get isStep2(): boolean {
|
|
198
|
+
return this.currentStep === 2;
|
|
199
|
+
}
|
|
200
|
+
public get isStep3(): boolean {
|
|
201
|
+
return this.currentStep === 3;
|
|
202
|
+
}
|
|
203
|
+
public get showPrev(): boolean {
|
|
204
|
+
return this.currentStep > 1;
|
|
205
|
+
}
|
|
206
|
+
public get nextLabel(): string {
|
|
207
|
+
return this.isLastStep ? "Submit" : "Next";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
public async handleNext(): Promise<void> {
|
|
211
|
+
const stepComponent = this.template.querySelector<StepComponent>('[data-id="current-step"]');
|
|
212
|
+
if (!stepComponent) return;
|
|
213
|
+
|
|
214
|
+
if (!stepComponent.validate()) return;
|
|
215
|
+
|
|
216
|
+
this.formData = { ...this.formData, ...stepComponent.getFormData() };
|
|
217
|
+
|
|
218
|
+
if (this.isLastStep) {
|
|
219
|
+
await this.submitAll();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.currentStep += 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public handlePrev(): void {
|
|
227
|
+
this.currentStep -= 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async submitAll(): Promise<void> {
|
|
231
|
+
try {
|
|
232
|
+
await createRecord({
|
|
233
|
+
apiName: CONTACT_OBJECT.objectApiName,
|
|
234
|
+
fields: this.formData,
|
|
235
|
+
});
|
|
236
|
+
this.dispatchEvent(new ShowToastEvent({ title: "Success", message: "Onboarding complete.", variant: "success" }));
|
|
237
|
+
} catch (error) {
|
|
238
|
+
this.dispatchEvent(
|
|
239
|
+
new ShowToastEvent({
|
|
240
|
+
title: "Submission failed",
|
|
241
|
+
message: reduceErrors(error).join(", "),
|
|
242
|
+
variant: "error",
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```html
|
|
251
|
+
<!-- onboardingWizard.html -->
|
|
252
|
+
<template>
|
|
253
|
+
<template lwc:if="{isStep1}">
|
|
254
|
+
<c-onboarding-step1 data-id="current-step"></c-onboarding-step1>
|
|
255
|
+
</template>
|
|
256
|
+
<template lwc:if="{isStep2}">
|
|
257
|
+
<c-onboarding-step2 data-id="current-step"></c-onboarding-step2>
|
|
258
|
+
</template>
|
|
259
|
+
<template lwc:if="{isStep3}">
|
|
260
|
+
<c-onboarding-step3 data-id="current-step"></c-onboarding-step3>
|
|
261
|
+
</template>
|
|
262
|
+
|
|
263
|
+
<div>
|
|
264
|
+
<template lwc:if="{showPrev}">
|
|
265
|
+
<lightning-button label="Back" onclick="{handlePrev}"></lightning-button>
|
|
266
|
+
</template>
|
|
267
|
+
<lightning-button variant="brand" label="{nextLabel}" onclick="{handleNext}"></lightning-button>
|
|
268
|
+
</div>
|
|
269
|
+
</template>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Quick Decision Guide
|
|
275
|
+
|
|
276
|
+
| Situation | Pattern |
|
|
277
|
+
| -------------------------------- | ------------------------------------------------------------------- |
|
|
278
|
+
| Simple create/edit single record | `lightning-record-edit-form` |
|
|
279
|
+
| Conditional fields | Custom form + `get` derived from `formState` |
|
|
280
|
+
| Multi-step wizard | Parent owns `formData`; step exposes `validate()` + `getFormData()` |
|
|
281
|
+
| Per-field change handlers | ❌ Use single `handleChange` keyed on `name` |
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
**Reference:** [Build Custom UI to Create and Edit Records — Salesforce LWC Developer Guide](https://developer.salesforce.com/docs/platform/lwc/guide/data-salesforce-write.html)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# template-track-immutable
|
|
2
|
+
|
|
3
|
+
**Impact: MEDIUM**
|
|
4
|
+
|
|
5
|
+
`@track` was required in early LWC to make object/array properties reactive. Since Spring '20, all properties are tracked deeply by default. The correct way to trigger reactivity is through immutable updates.
|
|
6
|
+
|
|
7
|
+
> **Rule:**
|
|
8
|
+
>
|
|
9
|
+
> - Do not use `@track` — it is no longer needed.
|
|
10
|
+
> - Trigger reactivity on objects: `this.obj = { ...this.obj, key: value }`.
|
|
11
|
+
> - Trigger reactivity on arrays: `[...arr, newItem]`, `arr.filter(...)`, `arr.map(...)`.
|
|
12
|
+
> - Never mutate an object or array in place and expect the template to re-render.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Case 1: Unnecessary `@track`, in-place mutation
|
|
17
|
+
|
|
18
|
+
**Incorrect:**
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// appointmentForm.ts
|
|
22
|
+
import { LightningElement, track } from "lwc";
|
|
23
|
+
|
|
24
|
+
interface AppointmentFormState {
|
|
25
|
+
date: string;
|
|
26
|
+
time: string;
|
|
27
|
+
notes: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// @ts-ignore
|
|
31
|
+
export default class AppointmentForm extends LightningElement {
|
|
32
|
+
// @ts-ignore
|
|
33
|
+
@track isLoading: boolean = false; // ❌ unnecessary
|
|
34
|
+
// @ts-ignore
|
|
35
|
+
@track errorMessage: string = ""; // ❌ unnecessary
|
|
36
|
+
// @ts-ignore
|
|
37
|
+
@track formState: AppointmentFormState = { date: "", time: "", notes: "" };
|
|
38
|
+
|
|
39
|
+
public handleChange(event: Event): void {
|
|
40
|
+
const target = event.target as HTMLInputElement;
|
|
41
|
+
// ❌ Mutates in place — template may not re-render reliably
|
|
42
|
+
(this.formState as Record<string, string>)[target.name] = target.value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Correct — no `@track`, immutable update:**
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// appointmentForm.ts
|
|
51
|
+
import { LightningElement } from "lwc";
|
|
52
|
+
|
|
53
|
+
interface AppointmentFormState {
|
|
54
|
+
date: string;
|
|
55
|
+
time: string;
|
|
56
|
+
notes: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// @ts-ignore
|
|
60
|
+
export default class AppointmentForm extends LightningElement {
|
|
61
|
+
isLoading: boolean = false;
|
|
62
|
+
errorMessage: string = "";
|
|
63
|
+
formState: AppointmentFormState = { date: "", time: "", notes: "" };
|
|
64
|
+
|
|
65
|
+
public handleChange(event: Event): void {
|
|
66
|
+
const target = event.target as HTMLInputElement;
|
|
67
|
+
// ✅ New object reference — LWC detects the change and re-renders
|
|
68
|
+
this.formState = { ...this.formState, [target.name]: target.value };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Case 2: Mutating an array in place
|
|
76
|
+
|
|
77
|
+
**Incorrect — push/splice mutates the existing reference, no re-render:**
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
// appointmentList.ts
|
|
81
|
+
import { LightningElement } from "lwc";
|
|
82
|
+
|
|
83
|
+
interface AppointmentRecord {
|
|
84
|
+
Id: string;
|
|
85
|
+
Name: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// @ts-ignore
|
|
89
|
+
export default class AppointmentList extends LightningElement {
|
|
90
|
+
appointments: AppointmentRecord[] = [];
|
|
91
|
+
|
|
92
|
+
public handleAdd(newAppointment: AppointmentRecord): void {
|
|
93
|
+
this.appointments.push(newAppointment); // ❌ mutates in place
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public handleRemove(appointmentId: string): void {
|
|
97
|
+
const index = this.appointments.findIndex((a) => a.Id === appointmentId);
|
|
98
|
+
this.appointments.splice(index, 1); // ❌ mutates in place
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Correct — new array reference:**
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// appointmentList.ts
|
|
107
|
+
import { LightningElement } from "lwc";
|
|
108
|
+
|
|
109
|
+
interface AppointmentRecord {
|
|
110
|
+
Id: string;
|
|
111
|
+
Name: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// @ts-ignore
|
|
115
|
+
export default class AppointmentList extends LightningElement {
|
|
116
|
+
appointments: AppointmentRecord[] = [];
|
|
117
|
+
|
|
118
|
+
public handleAdd(newAppointment: AppointmentRecord): void {
|
|
119
|
+
this.appointments = [...this.appointments, newAppointment]; // ✅
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public handleRemove(appointmentId: string): void {
|
|
123
|
+
this.appointments = this.appointments.filter((a) => a.Id !== appointmentId); // ✅
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public handleUpdate(updatedAppointment: AppointmentRecord): void {
|
|
127
|
+
this.appointments = this.appointments.map((a) =>
|
|
128
|
+
a.Id === updatedAppointment.Id ? { ...a, ...updatedAppointment } : a,
|
|
129
|
+
); // ✅
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public handleRemoveClick(event: Event): void {
|
|
133
|
+
const target = event.target as HTMLElement;
|
|
134
|
+
this.handleRemove(target.dataset.id ?? "");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Case 3: Updating a nested object property
|
|
142
|
+
|
|
143
|
+
**Incorrect — mutating nested property:**
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// profileForm.ts
|
|
147
|
+
public handleAddressChange(event: Event): void {
|
|
148
|
+
const target = event.target as HTMLInputElement;
|
|
149
|
+
// ❌ Mutates nested object — no re-render
|
|
150
|
+
this.formState.address.city = target.value;
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Correct — spread at every changed level:**
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// profileForm.ts
|
|
158
|
+
import { LightningElement } from "lwc";
|
|
159
|
+
|
|
160
|
+
interface Address {
|
|
161
|
+
city: string;
|
|
162
|
+
street: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface ProfileFormState {
|
|
166
|
+
name: string;
|
|
167
|
+
address: Address;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// @ts-ignore
|
|
171
|
+
export default class ProfileForm extends LightningElement {
|
|
172
|
+
formState: ProfileFormState = {
|
|
173
|
+
name: "",
|
|
174
|
+
address: { city: "", street: "" },
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
public handleAddressChange(event: Event): void {
|
|
178
|
+
const target = event.target as HTMLInputElement;
|
|
179
|
+
this.formState = {
|
|
180
|
+
...this.formState,
|
|
181
|
+
address: {
|
|
182
|
+
...this.formState.address,
|
|
183
|
+
[target.name]: target.value,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Quick Decision Guide
|
|
193
|
+
|
|
194
|
+
| Situation | Pattern |
|
|
195
|
+
| ----------------------------- | ------------------------------------------ |
|
|
196
|
+
| Update a primitive property | Direct assignment: `this.value = newValue` |
|
|
197
|
+
| Update one field in an object | `this.obj = { ...this.obj, field: value }` |
|
|
198
|
+
| Add item to array | `this.arr = [...this.arr, newItem]` |
|
|
199
|
+
| Remove item from array | `this.arr = this.arr.filter(...)` |
|
|
200
|
+
| Update item in array | `this.arr = this.arr.map(...)` |
|
|
201
|
+
| Update nested object | Spread at every changed level |
|
|
202
|
+
| Should I use `@track`? | No |
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
**Reference:** [Reactivity — Salesforce LWC Developer Guide](https://developer.salesforce.com/docs/platform/lwc/guide/reactivity.html)
|