Files
v10/.claude/skills/component/references/videojs-element.md
T

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 = truefirstUpdated() (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-is
  • Booleantrue if attribute is present, false if absent
  • Number — attribute value parsed via Number()

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:

  1. constructor() — Call super(). Initialize field defaults. No DOM access.

  2. connectedCallback() — Element added to DOM. Always call super.connectedCallback(). Start subscriptions here. The first update is scheduled automatically.

  3. attributeChangedCallback(name, old, new) — Attribute changed. Handled automatically for declared properties — you rarely need to override this.

  4. willUpdate(changed: PropertyValues) — Called before update(). Use for computing derived state from changed properties.

  5. update(changed: PropertyValues) — Performs the DOM update. Always call super.update(changed). This is where you write to the DOM.

  6. 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). hasUpdated is true during this call.

  7. updated(changed: PropertyValues) — Called after every update() (including the first). hasUpdated is true during this call. Use for post-update side effects like focus management or external library syncing.

  8. disconnectedCallback() — Element removed. Always call super.disconnectedCallback(). Clean up subscriptions.

Update control:

  • requestUpdate(name?, oldValue?) — Manually trigger an update cycle
  • isUpdatePendingboolean, true while an update is queued or in progress
  • hasUpdatedboolean, false until the first update cycle completes
  • performUpdate() — Synchronously flush a pending update (no-op if none pending)
  • scheduleUpdate() — Override to change update timing (e.g., use requestAnimationFrame). Default calls performUpdate().
  • 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 updates
  • getUpdateComplete() — 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 DOM
  • hostDisconnected() — Called when the host element disconnects
  • hostUpdate() — Called before the host's willUpdate/update
  • hostUpdated() — Called after the host's update, before updated()

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 super in 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 patternsthis.arr = [...this.arr, item] not push()
  • Wait for updateComplete before asserting DOM state in tests
  • Use useDefineForClassFields: false in 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 super calls in lifecycle methods

Common Mistakes

  1. Forgetting super calls: connectedCallback() { super.connectedCallback(); ... }
  2. Boolean defaults: Must default to false for attribute coercion to work correctly
  3. Memory leaks: Add listeners in connectedCallback, remove in disconnectedCallback
  4. Mutating instead of replacing: this.items = [...this.items, item] not push()
  5. Missing tsconfig setting: useDefineForClassFields: false is required