skip to content

What is a custom element's constructor allowed to do, and why does the specification forbid setting attributes or adding light-DOM children there?

level: middleimportance: should knowfreq 50%

answer

  1. two creation paths, one constructor
  2. createElement must return a bare element
  3. upgrade meets an element that already has content
  4. initialise here, react there
  5. shadow root is not a light-DOM child

basics

~20 s

The constructor may call super(), set instance fields and attach a shadow root, but must not touch attributes, add children, or inspect the document. Doing so breaks document.createElement, which throws NotSupportedError if the constructed element already has attributes or children.

solid answer

~40 s

A custom element constructor must call `super()` first and should then do nothing but initialise instance state — private fields, bound handlers, and optionally `this.attachShadow({ mode: 'open' })` with its shadow content. It must not call `setAttribute`, must not append light-DOM children, and must not inspect its own attributes or children, because at construction time it may have none yet. Two paths make that guarantee necessary. `document.createElement('my-card')` must hand back an element indistinguishable from a bare one, so the spec checks afterwards and throws `NotSupportedError` if the constructor added attributes or children. And in the upgrade path, the element already exists in the document with author-written attributes and children, so anything the constructor set would clobber real content. The rule is simple in practice: the constructor initialises, `connectedCallback` and `attributeChangedCallback` react.

go deeper

for a junior

Remember the rule of thumb: call super() first, then only initialise fields and optionally attach a shadow root. Attributes and children are off limits until connectedCallback.

for a middle

Explain why: createElement must return a bare element and throws NotSupportedError otherwise, while an upgraded element already carries the author's attributes and children that a constructor would clobber.

for a senior

Point out the failure mode this prevents — a component that works in hand-written HTML and breaks under programmatic creation — and show where each kind of setup belongs, including guarding defaults because connectedCallback can run repeatedly.

for a principal

Treat the constructor contract as an API-design constraint for your library: components must be creatable by any consumer, framework or parser, so codify the initialise-versus-react split in a base class or lint rule rather than leaving it to reviewer memory.

## The requirements, as the spec states them A custom element constructor is heavily constrained. The *custom element conformance* rules say it must: - call `super()` before touching `this`; - not return a different object; - not gain any attributes or children; - not inspect attributes, children or the document, because none of them are guaranteed to be there; - take no required arguments, since the browser constructs the element itself. What it *may* do: set instance fields, bind handlers, and attach a shadow root and populate it. A shadow root is explicitly not a child in the light DOM sense, which is why the `attachShadow`-in-constructor pattern is both legal and idiomatic. ```js class MyCard extends HTMLElement { #internalsReady = false; constructor() { super(); // required, and first this.attachShadow({ mode: 'open' }); // allowed this.#onClick = this.#onClick.bind(this); // this.setAttribute('role', 'group'); // NOT allowed here } } ``` ## Why the restriction exists: two creation paths The rule looks arbitrary until you notice that a custom element instance can come into existence two different ways, and the constructor runs at a different moment in each. **Path one — created empty.** `document.createElement('my-card')` or `new MyCard()` constructs a bare element. The DOM contract for `createElement` is that it returns an element with no attributes and no children, exactly like `createElement('div')`. If your constructor added a `role` attribute or appended a `<span>`, that contract would be broken. The spec enforces it rather than trusting you: after the constructor returns, the create-element algorithm checks the element and throws a `NotSupportedError` `DOMException` if it has attributes or children. Your element then fails to construct at all — a loud failure, deliberately. **Path two — upgraded.** The element already exists in the document, written by the HTML author, complete with attributes and children, and the definition arrives later. Upgrading runs your constructor *on that existing element*. Now the opposite hazard applies: the element already carries `<my-card title="Hello"><p>Body</p></my-card>`, and a constructor that assumed emptiness and appended its own markup would duplicate or destroy real content. The forbidding rule reconciles both paths with one instruction: assume nothing about the element's contents at construction time, and change nothing about them. ## Why inspecting is as bad as mutating Reading is not forbidden by an exception, but it is a conformance violation for the same reason: in the `createElement` path, `this.getAttribute('title')` is `null` and `this.children` is empty, while in the upgrade path both are populated. Code that branches on them behaves differently depending on how the element happened to be created — one of the most confusing bug classes in the whole custom elements API, because the same component works in a hand-written HTML page and fails when a framework calls `createElement`. ## Where the work goes instead - **Attribute-driven state** → `attributeChangedCallback`, backed by `static get observedAttributes()`. At upgrade time the browser replays it for every observed attribute already present, so you see the author's configuration without reading it yourself. - **Rendering, subscriptions, DOM reads** → `connectedCallback`, which runs after the attribute callbacks and only once the element is actually in a document. - **Reflecting state back out as attributes** → also `connectedCallback` or later, never the constructor. ```js static get observedAttributes() { return ['title']; } attributeChangedCallback(name, _old, value) { if (name === 'title') this.#title = value; if (this.isConnected) this.#render(); } connectedCallback() { if (!this.hasAttribute('role')) this.setAttribute('role', 'group'); this.#render(); } ``` Note the `hasAttribute` guard before setting `role`: because `connectedCallback` can run more than once, and because the author may have set the attribute themselves, defaults are applied conditionally. ## The one-time-work question Since `connectedCallback` may run repeatedly, people ask where genuinely one-time setup goes. The constructor is the right place for anything that touches only instance state — creating the shadow root, allocating fields, building a template clone into the shadow tree. Anything that touches the light DOM, the document, or attributes waits, and repeated `connectedCallback` invocations are made idempotent with a flag or a `hasAttribute`-style guard.

  • Why is attaching a shadow root inside the constructor allowed when appending children is not?
    Because a shadow root is not a child in the light DOM. The `createElement` check looks at the element's attributes and its child nodes; shadow content lives in a separate tree hanging off the element, so it neither violates the bare-element contract nor collides with author-written children in the upgrade path. It is the recommended place to do it.
  • What actually happens if a constructor calls this.setAttribute('role', 'group')?
    Nothing at all when the element is upgraded from existing markup — but `document.createElement('my-card')` throws a `NotSupportedError` because the created element must have no attributes. The result is a component that works in a hand-written page and breaks the moment a framework or a factory creates it programmatically.
  • Where do you put a default attribute value if the constructor cannot set it?
    In `connectedCallback`, guarded so it does not overwrite what the author wrote: `if (!this.hasAttribute('role')) this.setAttribute('role', 'group')`. The guard matters twice over — the author may have supplied a value, and connectedCallback can run again after a move.

saying these in an interview costs you the question

  • Sets attributes or appends children in the constructor
  • Reads this.children in the constructor to configure the element
  • Thinks the constructor runs only for elements created by createElement
  • Believes attachShadow in the constructor is also forbidden
  • Puts default attribute values in the constructor without a guard

context