LearnFrenzy
Sign in Take a free test

The DOM and Events in LWC · Lesson 17 of 25

Shadow DOM, querySelector and lwc:ref in LWC

6 min read Updated Nov 8, 2026 Saurabh Samir

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)

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')}`;
    }
}

Result

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') → null
  • this.template is the component's shadow root. this.template.querySelector searches your template only – not the insides of child components.
  • querySelectorAll returns a NodeList; spread it ([...inputs]) to use map and filter.
  • Select by data-* attributes or classes. Don't use id attributes for this: LWC rewrites ids in the rendered HTML to keep them unique, so #id selectors are unreliable.
  • this.querySelector (without template) 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;
    }
}

Result

[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.refs is available after the component renders; inside lwc:if a ref is undefined while the branch is hidden.
  • lwc:ref can't be used inside for:each loops – use data-* and querySelectorAll there.
  • Note the "pending" flag: renderedCallback runs 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

  1. Validate every lightning-input in 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 of every) calls reportValidity() on every input so all errors are shown, not just the first.

  2. Inside connectedCallback, this.refs.chart is undefined. Why?

    Show answer

    The template hasn't rendered yet in connectedCallback. Use the element in renderedCallback (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.

Next lesson

Search LearnFrenzy

Esc

Quick links