Detecting Slot Changes with slotchange: The Only Safe Read Point for Projected Content
Every component that reacts to what a consumer projects into it — collapsing an empty region, counting tabs, forwarding a heading level, wiring keyboard navigation across projected items — needs to know when that content is actually there. The obvious place to look is connectedCallback, and it is wrong often enough to be a reliable source of production bugs. The correct read point is the slotchange event, and understanding why requires understanding what the HTML parser has and has not done by the time your callback runs.
This page sits under Slot Composition & Content Projection in Core Architecture & Lifecycle Management. It reproduces the empty-read bug, grounds it in the parser’s incremental element upgrade, and gives a production pattern that behaves identically for parser-created, script-created, and server-rendered elements.
Problem Statement: The Component That Thinks It Is Empty
The component below tries to hide its footer region when no footer content is projected. It reads the slot once, at the end of connectedCallback. Loaded from a server as static HTML, it hides the footer even when the consumer supplied one.
<data-panel>
<span slot="footer">Updated 4 minutes ago</span>
</data-panel>
class DataPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; border: 1px solid #cbd5f5; border-radius: 8px; padding: 1rem; }
footer { margin-top: 0.75rem; font-size: 0.85rem; opacity: 0.75; }
footer[hidden] { display: none; }
</style>
<slot></slot>
<footer><slot name="footer"></slot></footer>`;
}
connectedCallback() {
const slot = this.shadowRoot.querySelector('slot[name="footer"]');
const footer = this.shadowRoot.querySelector('footer');
// BUG: reads assignment before the parser has appended the child.
footer.hidden = slot.assignedElements().length === 0;
console.log('assigned at connect:', slot.assignedElements().length);
}
}
customElements.define('data-panel', DataPanel);
Run this against server-delivered HTML with the definition loaded in a blocking <script> in the head and the console prints assigned at connect: 0. The footer is hidden even though the <span slot="footer"> is sitting right there in the source. Move the same definition to a deferred module and the log prints 1, the footer appears, and the bug quietly disappears — which is exactly what makes it dangerous. It is timing-dependent, so it survives local development and surfaces after a bundler change.
Root-Cause Analysis
The HTML Standard requires the parser to run the custom element reactions for an element as soon as its start tag has been processed and the element has been inserted into the document — not when its end tag is reached. A parser-created element is therefore constructed, upgraded, and connected while its own children are still being tokenised. The specification is explicit that connectedCallback may observe an incomplete child list; this is not an implementation quirk that a future browser will fix.
Slot assignment, defined by the DOM Standard’s assign slottables for a tree algorithm, runs whenever the host’s child list changes. Each appended child triggers a reassignment, and the affected slots have a slotchange event queued. That event is fired at the microtask checkpoint, which coalesces every mutation from the same task into one event per slot. So by the time any slotchange handler runs, assignment for that task is complete and assignedElements() returns a settled answer.
The reason the bug hides during development is script timing. A type="module" script is deferred, so the class definition is registered after the parser has finished the document. The element is then upgraded by customElements.define, which runs the upgrade reaction on an element that already has all its children — so the read at connection time happens to be correct. Switch to a classic blocking script, add a preload, or server-render with an inline definition, and the upgrade moves back to parse time where the child list is empty.
setTimeout(…, 0) is not a fix. Deferring the read by a task usually makes the symptom go away, which is why it appears in so many codebases. It is still wrong: a consumer that appends footer content later, or a framework that patches children during hydration, changes assignment after your timeout has already fired, and the component never updates. The event exists precisely so that the component tracks assignment for its whole lifetime rather than sampling it once at an arbitrary moment.
There is a second, quieter consequence of coalescing. slotchange fires only for slots whose assignment actually changed. A slot that starts empty and stays empty never fires at all, so an initialiser that lives only inside the handler never runs for the empty case. Any state derived from projection therefore needs a defined initial value in the template — hidden present by default, a count of zero, a disabled control — with the handler correcting it once content arrives.
Production-Safe Fix
The pattern below is the one to standardise on. State is derived from a single function, the template ships the empty state as its default, and every listener is scoped to an AbortSignal so a moved component does not accumulate duplicates.
class DataPanel extends HTMLElement {
#controller = null;
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; border: 1px solid #cbd5f5; border-radius: 8px; padding: 1rem; }
footer { margin-top: 0.75rem; font-size: 0.85rem; opacity: 0.75; }
footer[hidden] { display: none; }
</style>
<slot></slot>
<!-- Ships hidden: the empty state is the default, the event corrects it. -->
<footer hidden><slot name="footer"></slot></footer>`;
}
connectedCallback() {
this.#controller = new AbortController();
const { signal } = this.#controller;
const slot = this.shadowRoot.querySelector('slot[name="footer"]');
slot.addEventListener('slotchange', () => this.#syncFooter(slot), { signal });
// Covers the script-created case, where children exist before connection
// and no slotchange will ever fire for them.
this.#syncFooter(slot);
}
disconnectedCallback() {
this.#controller?.abort();
this.#controller = null;
}
#syncFooter(slot) {
// assignedElements() ignores whitespace text nodes; assignedNodes() would not.
const projected = slot.assignedElements();
const footer = this.shadowRoot.querySelector('footer');
footer.hidden = projected.length === 0;
this.dispatchEvent(new CustomEvent('footer-change', {
detail: { count: projected.length },
bubbles: true,
composed: true
}));
}
}
customElements.define('data-panel', DataPanel);
Three decisions in that code carry the correctness:
- The template ships the empty state.
<footer hidden>means a component whose footer slot is never filled is correct without any event having fired. - The same derivation runs from both entry points. Calling
#syncFooteronce at the end ofconnectedCallbackcoversdocument.createElement('data-panel')followed byappend, where the children are present before connection and noslotchangefollows. Running it again from the event covers the parser and every later mutation. Because the function derives state rather than toggling it, running twice is harmless. - Teardown is unconditional.
disconnectedCallbackaborts, and the next connection creates a fresh controller. Moving the element withappend— which disconnects then reconnects — leaves exactly one listener.
For components that need the effective content rather than the assigned content, pass { flatten: true }. That resolves slots nested inside other slots and returns fallback content when nothing is assigned, which is what you want when a component wraps another component that itself projects.
Verification
Assert the behaviour at the two moments that differ, not just the happy path:
// 1. Parser-created path: the element and its child come from markup.
document.body.innerHTML = '<data-panel><span slot="footer">ok</span></data-panel>';
await new Promise((resolve) => queueMicrotask(resolve)); // let slotchange flush
const parsed = document.querySelector('data-panel');
console.assert(!parsed.shadowRoot.querySelector('footer').hidden, 'footer must be visible');
// 2. Script-created path: children appended before connection, no slotchange fires.
const made = document.createElement('data-panel');
const span = document.createElement('span');
span.slot = 'footer';
span.textContent = 'ok';
made.append(span);
document.body.append(made);
console.assert(!made.shadowRoot.querySelector('footer').hidden, 'sync fallback must run');
// 3. Empty path: no event ever fires, the template default must hold.
const empty = document.createElement('data-panel');
document.body.append(empty);
console.assert(empty.shadowRoot.querySelector('footer').hidden, 'empty footer stays hidden');
In DevTools, the fastest confirmation is to select the host in the Elements panel and expand #shadow-root. A correctly composed slot shows a reveal link beside it pointing at the light-DOM node it received; an empty slot shows its fallback content instead. Switching to the Event Listeners tab and confirming exactly one slotchange entry after moving the element around the DOM verifies the teardown.
When to Use vs When to Avoid
| Situation | slotchange |
MutationObserver on the host |
One-shot read |
|---|---|---|---|
| React to what a consumer projects | Use — reports settled assignment | Overkill, fires on unrelated mutations | Unreliable at parse time |
| React to attribute changes on projected nodes | No — assignment did not change | Use — observe attributes with subtree |
No |
| Count or index projected items | Use with assignedElements() |
Redundant | Only if content is guaranteed static |
| Detect content inside a projected node | No — nested changes do not reassign | Use | No |
| Component created entirely by script | Pair with a sync call at connect | Not needed | Acceptable but fragile |
The rule of thumb: slotchange answers “which nodes are assigned to this slot”, and nothing else. Any question about what is inside those nodes is a MutationObserver question, with the teardown obligations set out in disconnecting observers in disconnectedCallback.
Frequently Asked Questions
Does slotchange bubble out of the shadow root?
No. The event is dispatched on the <slot> element with bubbles: true inside the shadow tree, but composed is false, so it stops at the shadow root and never reaches the host’s ancestors. Listeners must be registered inside the component, on the slot or on the shadow root.
Why does slotchange fire when I only changed whitespace?
Text nodes are slottables, and a reformatted template that puts children on separate lines adds whitespace text nodes to the default slot. Reading with assignedElements() rather than assignedNodes() filters them out of your logic, though the event itself still fires.
Do I still need the synchronous call if I always create elements from markup?
Keep it. It costs one function call, it makes the component correct for consumers who build it with createElement, and it removes the class of bug where a test that constructs the element in script behaves differently from the browser that parses it.
Related
- Slot Composition & Content Projection — the parent topic on the flattened tree and assignment.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- Imperative Slot Assignment with assign() — manual mode, where assignment is yours to trigger.
- Flattening Nested Slot Trees with assignedElements() — resolving projection through several components.
- Understanding connectedCallback Execution Order — why the parser upgrades elements before their children exist.