The DOM and Events in LWC · Lesson 17 of 25
Shadow DOM, querySelector and lwc:ref in LWC
In a normal web page, document.querySelector('.total') finds an element anywhere. In LWC it returns null – and that's on purpose. Each component's markup lives inside its own shadow DOM, a private subtree that outside code can't reach into. This lesson shows how to find your own elements, when you should touch the DOM at all, and when you shouldn't.
In this lesson
- What the shadow DOM is and why LWC uses it
- this.template.querySelector and querySelectorAll
- lwc:ref – direct references to elements
- Declarative first: when not to touch the DOM
Each component is a sealed box
The DOM of a page with two components
- document
- <c-order-form> – the host element
- #shadow-root – only orderForm's own code reaches in here
- <lightning-input>, <p class="total">
- <c-address-picker> → its own #shadow-root (sealed again)
- #shadow-root – only orderForm's own code reaches in here
- <c-order-form> – the host element
The shadow boundary means a parent can't style or query a child's internals, a child can't reach up into the parent, and global CSS from the page doesn't leak in. Components can be dropped anywhere without breaking each other – the whole point of web components.
Finding your own elements
<!-- orderForm.html -->
<template>
<lightning-input label="Quantity" type="number" value="1" data-field="qty"></lightning-input>
<lightning-input label="Unit price" type="number" value="250" data-field="price"></lightning-input>
<lightning-button label="Calculate" onclick={calculate}></lightning-button>
<p class="total">Total: {total}</p>
<p>{note}</p>
</template>
// orderForm.js
import { LightningElement } from 'lwc';
export default class OrderForm extends LightningElement {
total = 0;
note = '';
calculate() {
// ✓ search inside this component's shadow root
const inputs = this.template.querySelectorAll('lightning-input');
const values = Object.fromEntries([...inputs].map((input) => [input.dataset.field, Number(input.value)]));
this.total = values.qty * values.price;
// ✗ the page-level document can't see into the shadow root
this.note = `document.querySelector('.total') → ${document.querySelector('.total')}`;
}
}
Quantity [1] Unit price [250] [Calculate]
Total: 0
› clear Quantity and type “4”
Quantity [4] Unit price [250] [Calculate]
Total: 0
› click “Calculate”
Quantity [4] Unit price [250] [Calculate]
Total: 1000
document.querySelector('.total') → nullthis.templateis the component's shadow root.this.template.querySelectorsearches your template only – not the insides of child components.querySelectorAllreturns aNodeList; spread it ([...inputs]) to usemapandfilter.- Select by
data-*attributes or classes. Don't useidattributes for this: LWC rewrites ids in the rendered HTML to keep them unique, so#idselectors are unreliable. this.querySelector(withouttemplate) searches the light DOM – content a parent passed into your<slot>.
lwc:ref: a direct handle
Since LWC introduced lwc:ref, you can name an element in the template and get it from this.refs – no selectors, no typos in class names:
<!-- quickNote.html -->
<template>
<lightning-button label="Add note" onclick={openEditor}></lightning-button>
<template lwc:if={isEditing}>
<lightning-textarea label="Note" lwc:ref="noteBox"></lightning-textarea>
<lightning-button label="Save" onclick={save}></lightning-button>
</template>
<p>{status}</p>
</template>
// quickNote.js
import { LightningElement } from 'lwc';
export default class QuickNote extends LightningElement {
isEditing = false;
status = 'No note yet';
focusPending = false;
openEditor() {
this.isEditing = true;
this.focusPending = true; // the textarea doesn't exist until the next render
}
renderedCallback() {
if (this.focusPending && this.refs.noteBox) {
this.refs.noteBox.focus();
this.focusPending = false; // only once
this.status = 'Editor open and focused';
}
}
save() {
const text = this.refs.noteBox.value?.trim();
this.status = text ? `Saved: "${text}"` : 'Nothing to save';
this.isEditing = false;
}
}
[Add note] No note yet › click “Add note” [Add note] Note [] [Save] Editor open and focused › type “Customer prefers email” into Note [Add note] Note [Customer prefers email] [Save] Editor open and focused › click “Save” [Add note] Saved: "Customer prefers email"
this.refsis available after the component renders; insidelwc:ifa ref isundefinedwhile the branch is hidden.lwc:refcan't be used insidefor:eachloops – usedata-*andquerySelectorAllthere.- Note the "pending" flag:
renderedCallbackruns after every render, so one-time work needs a guard.
Declarative first
Most "DOM manipulation" from plain-JavaScript days has a cleaner LWC equivalent. Change the data, and let the template update the DOM:
| Instead of… | Do this |
|---|---|
el.textContent = 'Saved' | a field {status} in the template |
el.style.display = 'none' | lwc:if={isVisible} |
el.classList.add('slds-hide') | class={rowClass} with a getter |
el.setAttribute('disabled', '') | disabled={isSaving} |
parent.appendChild(newRow) | add an item to the array behind for:each |
Reach for querySelector/refs for the things the template can't express: calling methods such as focus(), reportValidity() or scrollIntoView(), reading values from many inputs at once, or handing an element to a third-party library (with lwc:dom="manual" on the container so LWC leaves its contents alone).
Never use innerHTML with user data
Setting innerHTML with text that came from a record or a user is an XSS hole, and Lightning Web Security restricts it anyway. If you must render rich text, use lightning-formatted-rich-text, which sanitises the HTML.
Practice
Try it yourself
Validate every
lightning-inputin a form and only save when all are valid. Write the check.Show answer
const allValid = [...this.template.querySelectorAll('lightning-input')].reduce((ok, input) => input.reportValidity() && ok, true);–reduce(instead ofevery) callsreportValidity()on every input so all errors are shown, not just the first.Inside
connectedCallback,this.refs.chartis undefined. Why?Show answer
The template hasn't rendered yet in
connectedCallback. Use the element inrenderedCallback(with a guard) or in an event handler.
Quick check
Why does document.querySelector not find elements in your component?
They are inside the component's shadow root, which page-level queries can't enter.
Can you use lwc:ref inside for:each?
No – use data-* attributes with querySelectorAll.
Why avoid id selectors in LWC?
LWC rewrites ids in the rendered DOM to keep them unique.
Summary
- Each component's markup is in its own shadow DOM.
- Use this.template.querySelector(All) with classes or data-*, or lwc:ref with this.refs.
- Refs and queried elements exist only after rendering.
- Change data and let the template update the DOM; query only for methods like focus().
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.