8.6 KiB
@videojs/element
Core concepts for building web components with @videojs/element. For Video.js-specific component patterns, see html.md.
ReactiveElement Basics
ReactiveElement is our lightweight custom element base class (@videojs/element). It provides reactive properties, attribute reflection, batched updates, and reactive controllers — without Shadow DOM, static styles, or decorators.
import { ReactiveElement } from '@videojs/element';
import type { PropertyValues } from '@videojs/element';
class MyElement extends ReactiveElement {
static override properties = {
name: { type: String },
};
name = 'World';
protected override update(changed: PropertyValues): void {
super.update(changed);
this.textContent = `Hello, ${this.name}!`;
}
}
customElements.define('my-element', MyElement);
Reactive update cycle: property change → Object.is() check → requestUpdate() → microtask batch → scheduleUpdate() → performUpdate():
willUpdate() → hostUpdate() (controllers) → update() → hostUpdated() (controllers) → hasUpdated = true → firstUpdated() (first time only) → updated() → updateComplete resolves.
Light DOM only — elements render directly to this (e.g. this.textContent, this.appendChild()). No Shadow DOM, no createRenderRoot().
Reactive Properties
Properties declared via the static properties field:
| Option | Purpose |
|---|---|
type |
String, Boolean, or Number — used for attribute → property coercion |
attribute |
Custom attribute name (defaults to the property name) |
static override properties = {
// String property, attribute name matches property name
label: { type: String },
// Number property
count: { type: Number },
// Boolean property (attribute presence = true, absence = false)
disabled: { type: Boolean },
// Custom attribute name
negativeSign: { type: String, attribute: 'negative-sign' },
};
How attribute coercion works:
String— attribute value passed through as-isBoolean—trueif attribute is present,falseif absentNumber— attribute value parsed viaNumber()
Change detection uses Object.is() — setting a property to the same value does not trigger an update. This correctly handles NaN and -0.
Property storage uses Symbols internally. Prototype accessors are installed automatically when the class is registered via customElements.define().
TypeScript Configuration
Required to prevent class fields from shadowing reactive accessors:
{
"compilerOptions": {
"useDefineForClassFields": false
}
}
Without this, label = 'default' uses [[Define]] semantics which creates an own data property, bypassing the prototype getter/setter.
Lifecycle Methods
In execution order:
-
constructor()— Callsuper(). Initialize field defaults. No DOM access. -
connectedCallback()— Element added to DOM. Always callsuper.connectedCallback(). Start subscriptions here. The first update is scheduled automatically. -
attributeChangedCallback(name, old, new)— Attribute changed. Handled automatically for declared properties — you rarely need to override this. -
willUpdate(changed: PropertyValues)— Called beforeupdate(). Use for computing derived state from changed properties. -
update(changed: PropertyValues)— Performs the DOM update. Always callsuper.update(changed). This is where you write to the DOM. -
firstUpdated(changed: PropertyValues)— Called once after the very first update. Use for one-time setup that requires the DOM (e.g., measuring layout, adding event listeners that depend on rendered content).hasUpdatedistrueduring this call. -
updated(changed: PropertyValues)— Called after everyupdate()(including the first).hasUpdatedistrueduring this call. Use for post-update side effects like focus management or external library syncing. -
disconnectedCallback()— Element removed. Always callsuper.disconnectedCallback(). Clean up subscriptions.
Update control:
requestUpdate(name?, oldValue?)— Manually trigger an update cycleisUpdatePending—boolean,truewhile an update is queued or in progresshasUpdated—boolean,falseuntil the first update cycle completesperformUpdate()— Synchronously flush a pending update (no-op if none pending)scheduleUpdate()— Override to change update timing (e.g., userequestAnimationFrame). Default callsperformUpdate().updateComplete— Promise that resolves after the current update completes
class MyElement extends ReactiveElement {
static override properties = {
items: { type: String },
};
items = '';
#computedCount = 0;
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
if (changed.has('items')) {
this.#computedCount = this.items.split(',').length;
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
this.textContent = `${this.#computedCount} items`;
}
}
What we DON'T have (vs Lit)
These Lit features are not available in our ReactiveElement:
shouldUpdate()— no skipping updatesgetUpdateComplete()— no async update chaining
Reactive Controllers
Encapsulate reusable behavior with lifecycle hooks:
import type { ReactiveController, ReactiveControllerHost } from '@videojs/element';
class ClockController implements ReactiveController {
#host: ReactiveControllerHost;
#timerID?: number;
value = new Date();
constructor(host: ReactiveControllerHost) {
this.#host = host;
host.addController(this);
}
hostConnected(): void {
this.#timerID = window.setInterval(() => {
this.value = new Date();
this.#host.requestUpdate();
}, 1000);
}
hostDisconnected(): void {
clearInterval(this.#timerID);
}
}
// Usage
class MyElement extends ReactiveElement {
#clock = new ClockController(this);
protected override update(): void {
this.textContent = this.#clock.value.toLocaleTimeString();
}
}
Controller interface (aligned with Lit's ReactiveController):
hostConnected()— Called when the host element connects to the DOMhostDisconnected()— Called when the host element disconnectshostUpdate()— Called before the host'swillUpdate/updatehostUpdated()— Called after the host's update, beforeupdated()
Context API
Context enables data sharing without prop drilling. We re-export @lit/context from @videojs/element/context:
import { createContext, ContextProvider, ContextConsumer } from '@videojs/element/context';
// Define context
const userContext = createContext<User>('user-context');
// Provider
class MyApp extends ReactiveElement {
#provider = new ContextProvider(this, {
context: userContext,
initialValue: { name: 'Guest' },
});
setUser(user: User): void {
this.#provider.setValue(user);
}
}
// Consumer
class UserDisplay extends ReactiveElement {
#consumer = new ContextConsumer(this, {
context: userContext,
subscribe: true,
callback: () => this.requestUpdate(),
});
protected override update(): void {
const user = this.#consumer.value;
this.textContent = user?.name ?? 'Unknown';
}
}
Best Practices
Do
- Always call
superin lifecycle methods — maintains the reactive update cycle - Use
willUpdate()for derived state — computed before DOM update - Clean up in
disconnectedCallback()— prevents memory leaks - Use immutable data patterns —
this.arr = [...this.arr, item]notpush() - Wait for
updateCompletebefore asserting DOM state in tests - Use
useDefineForClassFields: falsein tsconfig for packages with reactive elements
Don't
- Don't mutate arrays/objects without reassigning — won't trigger updates
- Don't access DOM in constructor — element isn't connected yet
- Don't forget
supercalls in lifecycle methods
Common Mistakes
- Forgetting super calls:
connectedCallback() { super.connectedCallback(); ... } - Boolean defaults: Must default to
falsefor attribute coercion to work correctly - Memory leaks: Add listeners in
connectedCallback, remove indisconnectedCallback - Mutating instead of replacing:
this.items = [...this.items, item]notpush() - Missing tsconfig setting:
useDefineForClassFields: falseis required