Custom Elements
Web Components custom elements — define, connectedCallback, attributes vs properties, and autonomous vs customized built-ins.
- javascript
- custom-elements
- web-components
Custom Elements let you teach the browser new tags (<user-card>) with a class lifecycle. Combined with Shadow DOM and templates they form Web Components. Even if your app is React-only, design systems and embeds use them — and interviews ask for the lifecycle by name.
Define and register
class UserCard extends HTMLElement {
static get observedAttributes() {
return ['name', 'role'];
}
constructor() {
super(); // required first
this._root = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
disconnectedCallback() {
// remove listeners, observers
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
this.render();
}
render() {
const name = this.getAttribute('name') ?? '';
const role = this.getAttribute('role') ?? '';
this._root.innerHTML = `
<style>
:host { display: block; border: 1px solid #ccc; padding: 8px; }
.role { opacity: 0.7; font-size: 0.9em; }
</style>
<strong>${escapeHtml(name)}</strong>
<div class="role">${escapeHtml(role)}</div>
`;
}
}
customElements.define('user-card', UserCard);
<user-card name="Ada" role="Engineer"></user-card>
Names must include a hyphen. Registration is global per page.
Lifecycle cheat sheet
| Callback | When |
|---|---|
constructor |
Element created / upgraded |
connectedCallback |
Inserted into document |
disconnectedCallback |
Removed from document |
attributeChangedCallback |
Observed attribute changes |
adoptedCallback |
Moved to new document |
Create DOM in connectedCallback if you need attributes to be present; constructor runs too early for some cases (and for SSR upgrade timing).
Attributes vs properties
Attributes are strings in HTML. Properties are JS values on the instance.
class CounterEl extends HTMLElement {
#count = 0;
get count() {
return this.#count;
}
set count(n) {
this.#count = Number(n) || 0;
this.setAttribute('count', String(this.#count));
this.render?.();
}
static get observedAttributes() {
return ['count'];
}
attributeChangedCallback(name, _, value) {
if (name === 'count') this.#count = Number(value) || 0;
}
}
Reflect carefully to avoid infinite attribute ↔ property loops.
Autonomous vs customized built-ins
// autonomous
customElements.define('fancy-button', FancyButton);
// customized built-in (limited Safari history — check support)
class FancyButton extends HTMLButtonElement {}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// <button is="fancy-button">
Prefer autonomous elements for portability unless you need built-in semantics without reinventing accessibility.
Interop with frameworks
React can render custom elements as tags; property passing and event names need care (DOM events vs props). Prefer addEventListener for component events:
this.dispatchEvent(
new CustomEvent('count-change', {
detail: { count: this.#count },
bubbles: true,
composed: true, // cross shadow boundary
}),
);
Footguns
- Forgetting
super()in the constructor. - Unescaped
innerHTMLfrom attributes → XSS. - Doing heavy work every
attributeChangedCallbackwithout diffing. - Assuming Shadow DOM is required — light DOM custom elements exist.
- Double-defining the same tag throws.
Interview answer
“Custom elements subclass HTMLElement, register with customElements.define and a hyphenated name. Lifecycle hooks cover connect, disconnect, and attribute changes. I reflect attributes carefully, escape HTML, and use CustomEvent with composed for shadow-piercing events. They’re great for design-system primitives and cross-framework embeds.”
Related
Upgrade and whenDefined
// element may exist in HTML before the class loads
customElements.whenDefined('user-card').then(() => {
console.log('ready');
});
// progressive enhancement: unknown elements still render light DOM children
<user-card>
<img slot="avatar" src="a.jpg" alt="" />
</user-card>
Combine with <template> + shadow slots for content projection (template and slot). Form-associated custom elements (ElementInternals, formAssociated = true) integrate with native forms — advanced but important if you replace <input> with a widget and still want FormData to see values.
Further reading
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.
- Async Iteration and for await...ofAsync iterables, for await...of, and streaming data — how async iterators differ from Promise.all and when to use each.