skip to content

Custom Elements and Lifecycle

You will learn how to define a real HTML element in JavaScript and the callbacks the browser fires as it enters, leaves, and changes in the document. Interviewers ask because the upgrade timing question separates people who have shipped custom elements from people who have read about them.

on this pageshow

questions

6

What rules does customElements.define() enforce on the tag name and the class you pass it, and what happens when you break them?

level: juniorimportance: must knowfreq 70%

answer

  1. hyphen is a contract, not a style rule
  2. reserved namespace for future HTML
  3. two different DOMException types
  4. one class, one name, one time
  5. get() before define() in shared bundles

basics

~10 s

customElements.define(name, class) requires a lowercase name that contains a hyphen and a class that extends HTMLElement. An invalid name throws SyntaxError; registering a name twice, or one class under two names, throws NotSupportedError.

solid answer

~50 s

`customElements.define('my-card', MyCard)` registers a tag name in the document's custom element registry. The name must start with an ASCII lowercase letter, contain no uppercase, and contain at least one hyphen — the hyphen is what guarantees the name can never collide with a future built-in HTML element, and it is also why the parser treats an unknown hyphenated tag as an upgradeable `HTMLElement` rather than `HTMLUnknownElement`. A short list of hyphenated names is reserved (`annotation-xml`, `color-profile`, `font-face`, `missing-glyph` and a few others). The second argument must be a class that ultimately extends `HTMLElement`. Registration is once-only and irreversible: an invalid name throws a `SyntaxError`, defining the same name twice or the same constructor under two names throws a `NotSupportedError`, and there is no way to un-define. `customElements.get('my-card')` tells you whether a name is already taken.

go deeper

for a junior

Be ready to write the two-line define call from memory and state the name rules: lowercase, must contain a hyphen, extends HTMLElement. Say plainly that the hyphen prevents collisions with built-in HTML elements.

for a middle

Explain the mechanics behind the rules: which exception each violation throws, why a hyphenated unknown tag is an HTMLElement awaiting upgrade rather than HTMLUnknownElement, and what customElements.get and whenDefined are for.

for a senior

Show that you have hit the once-only registry in production — duplicate definitions from a twice-loaded bundle or a dev-server reload — and describe the get()-before-define guard plus what it silently trades away when two versions collide.

for a principal

Own the naming policy: who may claim a tag name across teams, whether components ship prefixed or version-suffixed names, and how you keep a shared library from being registered twice on a host page you do not control.

## What define() is for `customElements.define()` is the single entry point that turns a JavaScript class into a real HTML element. After it runs, the browser knows that every `<my-card>` in the document — past, present and future — is an instance of your class, gets your prototype and your methods, and receives your lifecycle callbacks. Before it runs, the same tag is just a generic element the browser is willing to keep around. It takes two required arguments and one optional one: ```js class MyCard extends HTMLElement {} customElements.define('my-card', MyCard); ``` ## The name rules A *valid custom element name* must: - start with an ASCII lowercase letter (`a`–`z`), - contain at least one hyphen (`-`), - contain no ASCII uppercase letters, - not be one of the reserved names: `annotation-xml`, `color-profile`, `font-face`, `font-face-src`, `font-face-uri`, `font-face-format`, `font-face-name`, `missing-glyph`. Those already have meaning in SVG and MathML. So `my-card`, `x-1`, and `acme-user-badge` are legal; `mycard`, `MyCard`, `1-card` and `font-face` are not. A bad name throws a `SyntaxError` `DOMException` at the `define()` call. The hyphen is not a naming convention — it is a forward-compatibility contract. HTML reserves the entire hyphen-free namespace for itself, so a future `<carousel>` element added to the standard can never break your page, and your `<acme-carousel>` can never be redefined out from under you. ## What the parser does with an unknown hyphenated tag This rule also has a concrete parsing consequence. A garbage tag such as `<blah>` becomes an `HTMLUnknownElement`. A hyphenated tag such as `<my-card>` becomes an `HTMLElement` marked internally as an *undefined custom element*: it sits in the DOM, keeps its attributes and children, and is a candidate for upgrade the moment a matching `define()` call happens. CSS can see the difference — `my-card:not(:defined)` matches only while it is still waiting, which is the standard way to hide un-upgraded elements and avoid a flash of unstyled content. ## The class rules The constructor argument must be a class (something constructable, not an arrow function) whose prototype chain ends at `HTMLElement`. Extending `HTMLElement` directly gives you an *autonomous* custom element. Extending a specific interface such as `HTMLButtonElement` gives you a *customized built-in*, and then you must also pass `{ extends: 'button' }` as the third argument so the registry knows which tag it decorates. ## Registration is once-only The registry is append-only for the lifetime of the document: - Defining a name that is already registered throws `NotSupportedError`. - Registering the *same constructor* under two different names also throws `NotSupportedError` — one class, one tag. - There is no `customElements.undefine()` and no way to swap an implementation. That matters in practice more than it sounds. Any bundle that ships a component library and might be loaded twice on one page — an app plus a widget, two micro-frontends, a hot-reloading dev server — will hit the duplicate-definition throw. The common guard is to check first: ```js if (!customElements.get('my-card')) { customElements.define('my-card', MyCard); } ``` This silently keeps whichever copy won the race, which is a tradeoff, not a fix: the two copies may be different versions. ## Related registry methods - `customElements.get(name)` → the constructor, or `undefined` if the name is free. - `customElements.whenDefined(name)` → a promise that resolves with the constructor once the name is registered; useful for code that must wait for a definition it does not own. - `customElements.upgrade(root)` → forces upgrade of already-created elements in a subtree, instead of waiting for them to be inserted into the document. None of these let you replace a definition. Registration is a one-way door, and designing around that — versioned tag names, a single owner per name — is part of shipping custom elements at scale.

  • Before any definition is registered, what does the DOM create for a `<my-card>` element written in the HTML source?
    An ordinary `HTMLElement` instance flagged as an undefined custom element — not `HTMLUnknownElement`, which is what a non-hyphenated unknown tag gets. It keeps its attributes and children, matches `:not(:defined)` in CSS, and is upgraded to your class as soon as a matching `define()` runs.
  • Can you register one class under two tag names, for example an alias?
    No. `customElements.define()` throws `NotSupportedError` if the constructor is already registered under another name. If you genuinely need two tags, define a trivial subclass for the second name, or generate the class from a factory function so each name gets its own constructor.
  • Why do component libraries wrap `define()` in an `if (!customElements.get(name))` guard?
    Because a duplicate definition throws and would break the whole script. If two copies of a library load on one page — an app plus an embedded widget, or a dev server reload — the guard keeps the first registration and skips the second. It is a crash guard, not a version-conflict solution: the surviving copy may be the wrong version.

The registry is like a domain-name registrar for tag names: hyphenated names are the public zone anyone may claim, hyphen-free names are reserved for the standard itself, and once you claim a name it is yours for the life of the page — no transfers, no re-registrations.

saying these in an interview costs you the question

  • Thinks the hyphen is a naming convention you can skip
  • Believes you can redefine or un-define a registered tag name
  • Says an unknown hyphenated tag becomes HTMLUnknownElement
  • Registers one class under several tag names as an alias
  • Thinks define() must run before the element appears in the HTML

context

open as a page

Which lifecycle callbacks does the browser call on a custom element registered with customElements.define(), and what triggers each one?

level: middleimportance: must knowfreq 80%

basics

~20 s

Four: connectedCallback when the element becomes connected to a document, disconnectedCallback when it is removed, adoptedCallback(oldDoc, newDoc) when it moves to another document, and attributeChangedCallback(name, oldValue, newValue) for attributes listed in the static observedAttributes array.

open as a page

In the custom elements API, what is the difference between an autonomous custom element and a customized built-in, and how do you declare and use each?

level: middleimportance: should knowfreq 35%

basics

~20 s

An autonomous custom element extends HTMLElement and is used as its own tag, such as <my-button>. A customized built-in extends a specific interface like HTMLButtonElement, is registered with { extends: 'button' }, and is used as <button is="my-button">, inheriting that element's behaviour.

open as a page

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%

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.

open as a page

A custom element's connectedCallback reads this.querySelector('.label') and gets null when the defining script runs inline in <head>, but finds the element when the same script is loaded as a module at the end of <body>. Why does the timing change the result?

level: seniorimportance: should knowfreq 40%

basics

~20 s

With the definition already registered, the HTML parser upgrades the element the moment it sees the start tag, so connectedCallback runs before the children are parsed. When the definition arrives after parsing, the upgrade finds a complete element, so the children are already there.

open as a page

A custom element registers a window resize listener and starts a fetch in connectedCallback. What goes wrong as the element is moved around the DOM, and how do you make the lifecycle safe?

level: seniorimportance: should knowfreq 45%

basics

~20 s

connectedCallback runs again on every reinsertion, so a move stacks up duplicate listeners and duplicate fetches. Everything it registers must be released in disconnectedCallback, and setup must be idempotent — and disconnectedCallback never runs on page unload.

open as a page