A custom element is a class registered against a tag name via customElements.define(). Registration and basic lifecycle are well understood; the rules below target the parts routinely implemented incorrectly.
Lifecycle: the parts that get implemented wrong
The callbacks themselves are well documented; these are the misconceptions that cause bugs:
connectedCallbackcan run more than once. It fires on every insertion, so moving the element re-runs it. Make setup idempotent — don't assume a single first run.connectedMoveCallbackskips the disconnect/reconnect pair on a move. When an element is relocated withNode.moveBefore(), this callback fires instead ofdisconnectedCallback→connectedCallback, so state, focus, and iframe/media playback survive the move. Define it when your setup or teardown is expensive or observably re-runs.- Tear down in
disconnectedCallback. Remove global listeners, timers, and observers or you leak; but the element may be re-inserted, so don't treat teardown as permanent. - Call
super()first in the constructor, and do not depend on the full DOM being present. Callingsuper()after touchingthisthrows aReferenceError. Attributes are usually (but not always) present; long lists of children may not have finished parsing. UseconnectedCallbackfor one-shot initialization,slotchangeto monitor direct children, andattributeChangedCallbackto monitor attributes.connectedCallbackmay still fire before all children are parsed, so do not ignore subsequentslotchange/attributeChangedCallbackcalls. attributeChangedCallbackfires only forobservedAttributes, and should stay cheap for attributes that don't affect output.- Lifecycle hooks and
observedAttributesare snapshotted at registration.customElements.define()reads both once; adding a hook or extendingobservedAttributeson the class after the call is silently ignored. In particular,attributeChangedCallbacknever fires unlessobservedAttributeswas already present atdefine()time.
Upgrade timing and :defined
An element can exist in the DOM before its class is registered (in server-rendered HTML, or when the script loads late). Until defined it is an inert HTMLElement with none of your behavior; when customElements.define() runs, the browser upgrades every matching element already in the document, running its constructor and connectedCallback then.
Two consequences models routinely miss:
- Attributes and children may already be present when your constructor/
connectedCallbackruns on upgrade. Read existing attributes inconnectedCallbackto sync initial state, and never assume the element started empty. - Children may still be missing even in
connectedCallback— the HTML parser can yield mid-parse of long child lists, so the callback sometimes runs before every child is attached. See the constructor bullet in Lifecycle above for the pattern (slotchange+attributeChangedCallback).
Hide undefined elements to avoid a flash of unstyled content — but fail open, so the content stays reachable if the script never loads. Animate opacity with a delay rather than hard-hiding with visibility: hidden, so the element reveals itself after a timeout even when it is never upgraded:
my-element:not(:defined) {
opacity: 0;
/* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }
Scope these guards to the elements that need them — or to a specific subtree via :has(:not(:defined)) — rather than the whole body. One slow, non-critical widget (a share button, say) should not delay paint for the entire page.
Attribute ⇄ property reflection
Keep the HTML attribute and the JS property in sync so the element is usable from both markup and script. Attribute values are always strings; coerce them.
class MyToggle extends HTMLElement {
static observedAttributes = ['label', 'count', 'disabled'];
// String attribute reflected via getter/setter.
get label() { return this.getAttribute('label') ?? ''; }
set label(v) {
v ? this.setAttribute('label', v) : this.removeAttribute('label');
}
// Numeric attribute: coerce from string on read, serialize on write.
get count() { return Number(this.getAttribute('count')) || 0; }
set count(v) { this.setAttribute('count', String(v)); }
// Boolean attribute: present = true, absent = false. Never set it to "false".
get disabled() { return this.hasAttribute('disabled'); }
set disabled(on) { this.toggleAttribute('disabled', !!on); }
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal !== newVal) this.render();
}
}
Where the value lives
The example above keeps the DOM as the source of truth — each getter reads getAttribute. That's the right default for a handful of simple, string-ish attributes: there is only one place the state can live, so it can't desync.
For richer components, keep the source of truth in a JS field and reflect outward to an attribute only when something else must see it — CSS targeting, or a consumer reading it. Reading a field is far cheaper than parsing and coercing an attribute on every access, and non-string values never round-trip through serialization. This is why libraries like Lit hold state in JS and reflect selectively rather than treating attributes as storage: reflect only what the outside world must observe, not every property.
Pitfalls:
observedAttributesdiscipline: list only attributes whose change must re-render or trigger a side effect. Observing everything adds a callback to every mutation for no benefit.- Reflection loops: a setter that writes an attribute will re-enter
attributeChangedCallback. Guard with anoldVal !== newValcheck, as above. - Style-only state: for internal state that exists only to drive styling (loading, active, expanded), prefer custom states over reflecting a synthetic attribute — see Custom states below. Reflect to an attribute only when consumers must be able to set the state from markup.
Custom states
Custom states are boolean flags an element exposes about itself for CSS to target, set through ElementInternals and matched with the :state() pseudo-class. Use them for internal, runtime UI state — busy, expanded, invalid — instead of reflecting a synthetic attribute: they can't collide with a consumer's attributes, never appear in the DOM, and are read-only from outside (a consumer can style :state() but can't forge it).
class AsyncButton extends HTMLElement {
#internals;
#action = async () => {};
constructor() {
super();
this.#internals = this.attachInternals();
}
set action(fn) { this.#action = fn; } // host supplies the work to run
async run() {
if (this.#internals.states.has('busy')) return; // guard re-entry
this.#internals.states.add('busy');
this.#internals.states.delete('success');
try {
await this.#action();
this.#internals.states.add('success');
} catch {
this.#internals.states.add('error');
} finally {
this.#internals.states.delete('busy');
}
}
}
CustomStateSet is a set, so independent states compose (busy plus error) without inventing a combined data-status enum.
Custom state is runtime-only. It is not serialized to the DOM, so it does not survive a reload, the element being re-created, or server rendering. If the state must be set declaratively in markup or persist across loads, that is a job for an attribute (see Where the value lives) — restore it in connectedCallback from your source of truth. Rule of thumb: custom state for what the component derives and owns at runtime and only needs to style; an attribute for what must be set from markup or serialized.
For styling these states — both inside the component and from a consumer's stylesheet — see Styling Web Components.
Naming conventions
- True-private state: native
#fields (#count) for hard encapsulation; a_prefix only when subclasses must still reach it. Note#fields are not inherited — a subclass cannot see a base class's private fields. - Symbol-keyed properties are a middle ground: hidden from ordinary enumeration, but reachable by any code holding the symbol (export it, or expose it as a
staticfield on the class). Because each symbol is unique, two symbols sharing a description never collide — handy when a subclass or sibling needs controlled access that#fields can't provide. - Do not shadow global attributes (
style,class,id,slot,part,title,lang,dir) with your own properties; doing so breaks their built-in behavior. Notedisabledis only global on form controls; on other elements you must implement its effect yourself.
When naming a new API surface, follow the platform's own conventions: use lowercase kebab-case with at least one hyphen for custom element tag names (for example my-toggle), lowerCamelCase for properties and methods, and lowercase attribute names synchronized with their corresponding properties.