Lesson 14 / 24
Custom Elements
Introducing a new tag name into the browser's element registry; the naming rule, the order of lifecycle callbacks, upgrading an element that entered the tree before its definition, and attribute-property reflection.
Contents
The previous lesson spread the load on the main thread out to worker threads: computation in one place, interface in another. Once the load is spread out, what remains is the interface’s own problem. The measurement badge on the station page — the small piece that shows a number with its value, unit, and threshold color — repeats five times on the page, fifteen times on the dashboard. Each repeat writes the same markup by hand, gives the same classes by hand, wires up the same listeners by hand.
The way to gather this repetition into one place is to introduce a new tag name to the browser: an element whose structure, behavior, and lifecycle are defined within itself and written in the document with a single line. The mechanism built in this lesson is called a custom element.
The Naming Rule and the Element Registry
The browser keeps tag names in a table: p, section, input, and the rest. A new name is
added to this table from a program; a class is given for the name, and the browser associates
every element with that name with an instance of that class. This table is called the
custom element registry.
The added name has one formatting rule: it must contain at least one hyphen and start with
a lowercase letter. measurement-badge is valid, badge is not. The reasoning behind the rule
is not restriction but protection. A tag name added to HTML later never contains a hyphen; the
hyphenated name space is thereby reserved for the document author. Without this separation, a
badge element defined today would collide with a badge tag that enters the standard later,
and the page would silently start doing something else.
A name can be defined only once. Trying to define the same name a second time produces an error; this is a check that catches two separate versions of the same component entering the same page early.
There are two kinds of custom elements. The first is an autonomous element that extends the
base element class and is written with its own tag name. The second extends an existing tag
and inherits that tag’s behavior; it is written in the document with the is attribute.
Support for the second kind is conditional and tested with feature detection; this lesson is
built on the first kind.
Lifecycle Callbacks
A custom element’s class is called by the browser at four points. These are called lifecycle callbacks.
The constructor runs when the element object is first created. The connected callback runs when the element enters a tree connected to the document. The disconnected callback runs when the element is removed from that tree. The attribute changed callback runs when the value of a previously declared attribute changes.
The last callback has one condition: the class must declare, with a static list, which attributes to watch. A change to an attribute not on the list produces no call. This is a deliberate limit that keeps every attribute write from triggering a callback.
The order differs from a hand-written setup routine and cannot be learned by guessing. The model below makes the call order visible.
// lifecycle.mjs — a small model of custom element lifecycle and upgrade const calls = []; const doc = { name: "#document", attrs: {}, children: [], parent: null, instance: null }; const isConnected = (n) => { let cur = n; while (cur.parent) cur = cur.parent; return cur === doc; }; function* allNodes(root) { yield root; for (const c of root.children) yield* allNodes(c); } const registry = new Map(); // Upgrade order is fixed: constructor -> observed attributes -> connectedCallback if connected. function upgrade(node) { const Class = registry.get(node.name); if (!Class || node.instance) return; node.instance = new Class(node); for (const name of Class.observedAttributes ?? []) if (name in node.attrs) node.instance.attributeChangedCallback(name, null, node.attrs[name]); if (isConnected(node)) node.instance.connectedCallback(); } // When an element is created, if a definition exists upgrade happens immediately; otherwise it stays undefined. function element(name, attrs = {}) { const node = { name, attrs, children: [], parent: null, instance: null }; upgrade(node); return node; } function define(name, Class) { if (!name.includes("-")) throw new Error(`invalid name: ${name}`); if (registry.has(name)) throw new Error(`already defined: ${name}`); registry.set(name, Class); for (const n of allNodes(doc)) if (n.name === name) upgrade(n); // in document order } function remove(node) { const siblings = node.parent.children; const wasConnected = isConnected(node); siblings.splice(siblings.indexOf(node), 1); node.parent = null; if (wasConnected) node.instance?.disconnectedCallback(); } function append(parent, child) { if (child.parent) remove(child); child.parent = parent; parent.children.push(child); if (!child.instance) upgrade(child); else if (isConnected(child)) child.instance.connectedCallback(); } function setAttribute(node, name, value) { const oldValue = node.attrs[name] ?? null; node.attrs[name] = value; const observed = registry.get(node.name)?.observedAttributes ?? []; if (node.instance && observed.includes(name)) node.instance.attributeChangedCallback(name, oldValue, value); } let counter = 0; class MeasurementBadge { static observedAttributes = ["value", "unit"]; constructor(node) { this.label = `badge#${++counter}`; calls.push(`${this.label} constructor`); } attributeChangedCallback(name, oldValue, newValue) { calls.push(`${this.label} attributeChangedCallback(${name}, ${oldValue}, ${newValue})`); } connectedCallback() { calls.push(`${this.label} connectedCallback`); } disconnectedCallback() { calls.push(`${this.label} disconnectedCallback`); } } // A. Two elements that entered the tree BEFORE the definition (the state a parser produces). const dashboard = element("section", { id: "dashboard" }); append(doc, dashboard); append(dashboard, element("measurement-badge", { value: "-4.2", unit: "C" })); append(dashboard, element("measurement-badge", { value: "72" })); calls.push("--- define('measurement-badge') ---"); define("measurement-badge", MeasurementBadge); // B. An element created AFTER the definition. calls.push("--- element created after definition ---"); const fresh = element("measurement-badge"); setAttribute(fresh, "value", "3.4"); append(dashboard, fresh); // C. A connected element is moved to another parent. calls.push("--- element moved to another parent ---"); const sidebar = element("aside", { id: "sidebar" }); append(doc, sidebar); append(sidebar, fresh); // D. A name without a hyphen. calls.push("--- name without a hyphen ---"); try { define("badge", MeasurementBadge); } catch (e) { calls.push(e.message); } console.log(calls.join("\n"));
--- define('measurement-badge') ---
badge#1 constructor
badge#1 attributeChangedCallback(value, null, -4.2)
badge#1 attributeChangedCallback(unit, null, C)
badge#1 connectedCallback
badge#2 constructor
badge#2 attributeChangedCallback(value, null, 72)
badge#2 connectedCallback
--- element created after definition ---
badge#3 constructor
badge#3 attributeChangedCallback(value, null, 3.4)
badge#3 connectedCallback
--- element moved to another parent ---
badge#3 disconnectedCallback
badge#3 connectedCallback
--- name without a hyphen ---
invalid name: badge
There are four things in the output worth reading.
First, the first seven lines show that no callback runs until the definition is called. The elements were in the tree, their attributes were in place; what was pending was the definition.
Second, the order is the same for every element: constructor, then one attribute-changed call for each observed attribute, then connection. Connection always comes last. This is why it is safe for a component to do its first render in the connected callback: attribute values have been read by that point.
Third, the second badge produced no call for the unit attribute, since it was not written in
the document; no default call is made for a missing attribute. Setting up a default value is
the class’s job.
Fourth, for the moved element, disconnection and connection ran back to back. Moving something in the tree is removing it and re-adding it. A binding rule follows from this: the connected callback can run more than once over an element’s lifetime, so it must be written to tolerate repetition. A connected callback that attaches a listener must, as its counterpart, remove that listener in the disconnected callback; otherwise a listener accumulates on every move.
What Cannot Be Done in the Constructor
The constructor is the element’s earliest stop, and this earliness brings a restriction. At the moment the constructor runs, the element may not yet be in the tree, its attributes may not have been read, its children may not have been parsed. The parser builds the instance the moment it sees a tag; it has not reached the closing tag yet.
Three rules follow from this. The constructor does not read or write attributes. The constructor does not add or read child nodes. The constructor does not access the document. The constructor’s only job is to set the element’s own fields to their initial values; everything concerning the document is left to the connected callback.
Parsing order has one more side effect: when the connected callback of a custom element in the middle of the document runs, that element’s children may not yet be parsed. A component that needs to read its children does this either by waiting for parsing to finish or by observing the child list; it cannot do it by counting at the moment of connection.
Upgrade and the Delay of Definition
Associating an element that entered the tree before its definition with the class once the definition arrives is called upgrade. Upgrade does not produce a new object: the object in the tree stays as it was, it gains the class’s behavior.
This means the page’s state before the definition is not a bug, it is an intermediate state. When the script carrying the definition loads deferred, the user briefly sees the document’s raw elements. This intermediate state is managed with style: there is a pseudo-class that selects undefined elements, and these elements can be hidden or shown in placeholder form until the definition arrives. The balance to strike is between showing empty space and showing unstyled content; the right choice for the measurement badge is to show the number in its raw form and withhold only the threshold color until the definition arrives — readable content is never hidden.
A waiting point is needed on the program side too: a promise that waits for a name to be defined and resolves once the definition arrives. Code that accesses an element’s properties before upgrade should await this promise; code that does not await it falls into the next section’s trap.
Reflection Between Attribute and Property
The distinction built in the DOM API lesson turns into a design decision here: an attribute is the text written in the document, a property is the living value on the program side. A custom element offers both and sets up a reflection between them: a value written to the property is written to the attribute, a value written to the attribute updates the property.
This two-way bond feeds itself if set up carelessly. The property setter writes the attribute, the attribute-changed callback writes the property, which writes the attribute again. The rule that breaks the loop is simple: before writing in either direction, check that the value has actually changed. A step rewriting the same value must cut the chain from continuing.
A second trap is sneakier and comes from the delay of upgrade.
// upgrade-trap.mjs — a property assigned before the definition shadows the accessor // Upgrade means replacing the element's prototype with the class prototype; the element object itself is unchanged. class Badge { get value() { return this.valueField ?? null; } set value(v) { this.valueField = Number(v); this.renderCount = (this.renderCount ?? 0) + 1; // on a real element, this would be a re-render } } function upgrade(el, { rescue }) { Object.setPrototypeOf(el, Badge.prototype); if (rescue && Object.hasOwn(el, "value")) { const pending = el.value; // the element's own property shadowing the accessor delete el.value; // remove the shadow el.value = pending; // assignment now goes to the accessor } } console.log("calling code assigns a value to the property before the definition loads:"); for (const rescue of [false, true]) { const el = {}; // element whose definition has not loaded yet el.value = "5"; // no accessor: this becomes the element's own property upgrade(el, { rescue }); console.log( ` rescue ${rescue ? "yes" : "no"} -> value: ${JSON.stringify(el.value)},`, `type: ${typeof el.value}, renders: ${el.renderCount ?? 0}`, ); }
calling code assigns a value to the property before the definition loads: rescue no -> value: "5", type: string, renders: 0 rescue yes -> value: 5, type: number, renders: 1
An assignment made to the property before the definition loads creates the element’s own property. Upgrade changes the prototype, but the accessor on the prototype stays underneath the element’s own property and is never called. In the first line, the value stays a string, no conversion happens, no render is triggered. The failure is silent: there is no error message, only a component that fails to work.
The rescue is a single pattern and is applied in the constructor: for every property the class defines, if the element has its own property of the same name, its value is taken, the property is deleted, and reassigned. Deleting exposes the accessor on the prototype; reassigning runs it. In the second line, the value has become a number and a render has triggered once.
Summary
- A custom element is adding a tag name and a class pair to the browser’s element registry; the name must contain at least one hyphen and be defined only once.
- The hyphen requirement separates the name space to prevent collision with tag names that enter the standard later.
- Lifecycle calls proceed in a fixed order: constructor, change calls for observed attributes, then connection; disconnection happens on removal from the tree.
- The connected callback can run more than once over an element’s lifetime; everything it sets up must be undone by the disconnected callback.
- The constructor does not read attributes, add children, or access the document; document work belongs to the connected callback.
- Elements present in the tree before the definition get upgraded; a value written to the property before the definition shadows the accessor and is rescued in the constructor with a delete-and-reassign pattern.
Next Step
This lesson gave the element a name and a lifecycle, but left its inside open. The badge’s markup still sits in the document’s shared tree: a style rule written anywhere on the page can leak into the badge, and the badge’s own rule can leak out; a selector on the page finds the badge’s internal nodes and touches them. What a reusable piece needs is exactly the opposite: its inside should not be visible from outside, its outside should not affect the inside. The next lesson examines the mechanism that opens a separate tree inside the document tree and builds this two-way isolation.
To keep your progress and take notes, Log in
My notes
Log in to take notes.