In a Web Component, a shadow-root listener recomputes on a slot's slotchange event, but it never fires when the caller edits the text inside an element that is already slotted. Why not, and what does slotchange actually signal?
answer
- boundary, not contents
- assignment changed, nothing else
- no payload, re-read the slot
- one event per task, not per node
- deep edits need a different observer
basics
~20 sslotchange signals that a slot's assigned node list changed — a slottable child added, removed, reordered, or re-routed by a slot attribute. It says nothing about mutations inside an already-assigned node, so editing that element's text or attributes fires nothing.
solid answer
~50 s`slotchange` is an *assignment* event, not a subtree-mutation event. It fires when the set of nodes assigned to that slot changes: a direct child of the host is added or removed, children are reordered, or a child's `slot` attribute changes so it moves between slots. Anything happening strictly *inside* an already-assigned node — text edits, attribute changes, descendants added — leaves the assignment identical, so no event is dispatched. If a component genuinely must react to deep changes in caller content, that is a `MutationObserver` on the light DOM subtree, not `slotchange`. Two more details worth stating: the event is dispatched asynchronously and coalesced, so appending three children in one task yields one `slotchange`, not three; and it bubbles within the shadow tree without being composed, which is why listening once on the `shadowRoot` is a common pattern. On the event, re-read the truth with `slot.assignedElements({ flatten: true })` — the event carries no payload of what changed.
code
javascript · 16 linesclass TagList extends HTMLElement {
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' }).innerHTML = '<slot></slot>';
this.shadowRoot.addEventListener('slotchange', () => this.refresh());
}
this.refresh(); // also run for a slot that starts empty
}
refresh() {
const slot = this.shadowRoot.querySelector('slot');
const items = slot.assignedElements({ flatten: true });
this.toggleAttribute('empty', items.length === 0);
items.forEach((el, i) => el.setAttribute('aria-posinset', String(i + 1)));
}
}
customElements.define('tag-list', TagList);go deeper
Know that slotchange tells a component its slotted content changed, and that the handler re-reads the slot with assignedElements() because the event carries no details.
Explain the exact scope: assignment changes only — child added, removed, reordered, or re-routed by a slot attribute — and nothing that happens inside an already-assigned node.
Show the operational detail: the event is asynchronous and coalesced per task, it bubbles within the shadow tree but is not composed, and deep content changes require a MutationObserver on the light DOM instead.
Own the design tradeoff: reacting to caller content at all couples a component to markup it does not own. Decide when assignment-level reactions suffice and when a data-driven property is the more stable contract.
## What the event is for A slot's job is to render whatever the caller currently puts in the host. The caller can change that at any time, and the component often needs to react — count the items, label the first one, hide an empty region, wire up the newly arrived children. `slotchange` is the notification that the *set of assigned nodes* is no longer what it was. That is the whole scope of it. The mental model to carry into an interview: `slotchange` watches the **boundary**, not the **contents**. ## What fires it - A slottable direct child of the host is inserted or removed. - The host's children are reordered, changing the order of the assignment. - A child's `slot` attribute is added, removed or changed, moving it between slots (this fires on both the losing and the gaining slot). - The initial assignment, if the slot has assigned nodes when it is first connected. A slot that starts empty fires nothing until something is assigned. ## What does not fire it - Text edits inside an assigned element (`slotted.textContent = 'x'`). - Attribute changes on an assigned element (`slotted.classList.add('on')`). - Descendants added or removed below an assigned element. - Changes anywhere in the shadow tree. All four leave the assigned-node list byte-for-byte identical, so from the slot's point of view nothing happened. ```js const slot = this.shadowRoot.querySelector('slot'); slot.addEventListener('slotchange', () => { this.items = slot.assignedElements({ flatten: true }); this.toggleAttribute('empty', this.items.length === 0); }); ``` If the requirement really is "react when the caller's content changes at any depth", the correct tool is a `MutationObserver` observing the host's light DOM with `childList`, `subtree` and `characterData` — a different API with a different cost profile, and one you should reach for deliberately rather than by accident. ## Timing and coalescing `slotchange` is not dispatched synchronously from the mutation. The browser flags the slot as changed and fires the event later in the same turn, alongside the mutation-observer callbacks. Two consequences: 1. **Batching.** Appending five children in a loop produces **one** `slotchange` for the slot, not five. That is a feature — you re-read the whole assignment anyway. 2. **You cannot rely on it having fired yet** immediately after a synchronous DOM mutation. Code that mutates and then instantly reads component state derived from `slotchange` will read the previous state. The event object itself is a plain `Event`: no `addedNodes`, no `removedNodes`. There is deliberately no diff — you call `assignedElements()` (or `assignedNodes()`) and take the current truth. If your component needs a diff, keep the previous array yourself and compare. ## Where to listen The event is fired at the `<slot>` element with bubbling enabled but `composed` false. Bubbling means a single listener on the `ShadowRoot` catches `slotchange` from every slot in the component: ```js this.shadowRoot.addEventListener('slotchange', (e) => { const slot = e.target; // which slot changed this.refresh(slot.name || 'default'); }); ``` Not being composed means it stops at the shadow boundary: the page outside cannot observe your component's internal slot churn, which is the right encapsulation default. ## The initial-assignment gotcha Components frequently do their setup in the `slotchange` handler and nowhere else. That works when the caller supplies content in the initial markup, because the first assignment fires the event. It silently does nothing when the slot starts empty and is filled later by a framework that renders children in a second pass — or, worse, it fires *before* the component is ready if setup happens in a later task. The robust shape is idempotent: a single `refresh()` that reads `assignedElements()` and can be called from both the connection path and every `slotchange`. ## The one-sentence answer "`slotchange` tells me *which nodes are now assigned to this slot* changed — nothing about what is inside them. It is asynchronous, coalesced per task, carries no diff, and if I need to see deep mutations in the caller's content I need a `MutationObserver` instead."
- How many slotchange events fire when a script appends four children to the host in one synchronous loop?One. The browser flags the slot as changed and dispatches the event later in the turn, together with mutation-observer callbacks, so multiple changes in the same task coalesce into a single notification per slot. That is why handlers re-read `assignedElements()` wholesale instead of processing per-node deltas.
- Does slotchange fire for a slot that has content in the initial markup?Yes — the initial assignment counts as a change, so a slot that receives nodes when it is first connected fires once. A slot that starts empty fires nothing until something is assigned, which is why setup code should be an idempotent refresh called both on connection and on every slotchange.
- Can code outside the component listen for its slotchange events on the host element?No. The event bubbles within the shadow tree but is not composed, so it stops at the shadow boundary — a listener on the host or on document never sees it. Listening on the component's own `shadowRoot` catches every slot; exposing the information outward requires dispatching your own event.
saying these in an interview costs you the question
- Treats slotchange as a general subtree-mutation notification
- Expects addedNodes and removedNodes on the event object
- Assumes one slotchange per inserted node
- Reads component state synchronously right after mutating children
- Puts all setup in slotchange and skips slots that start empty