@haiix/tcomponent
    Preparing search index...

    Architecture & Component Communication

    Building scalable applications with TComponent requires a solid understanding of how components interact and manage their lifecycles. By embracing the DOM as the primary source of truth, you can orchestrate complex UIs without the overhead of complex state management.

    This document covers component teardown, dynamic instantiation, error boundaries, event delegation, and explicit strategies for communication between components.


    Managing event listeners in vanilla JavaScript can often lead to memory leaks if elements are removed from the DOM but their listeners remain active. TComponent handles this automatically.

    Every TComponent instance comes with a built-in destroy() method. Calling this method is the standard and safest way to remove a component. It will automatically perform the following steps:

    1. Remove from DOM: The component's root element is detached from the document (this.element.remove()).
    2. Unbind Events: Unbind all event listeners defined via on* attributes in your template.
    3. Cascade Teardown: Automatically cascade the teardown process to all nested child components, ensuring no memory leaks remain.
    const app = new App();
    document.body.appendChild(app.element);

    // Later, when the app needs to be entirely removed:
    // This will safely remove the app from the DOM, unbind its listeners,
    // and recursively destroy all child components inside it.
    app.destroy();

    Calling .destroy() ensures that your application remains fast and memory-safe, without needing to manually call removeEventListener for every single element.

    (For advanced use cases like SPA routers or external signals, see Advanced Memory Management: External AbortSignals.)


    TComponent embraces the DOM as the primary source of truth. Instead of maintaining a complex virtual component tree, it provides a built-in static from(element) method. This allows you to retrieve the component instance associated with a specific DOM node.

    This is particularly useful when you need to interact with a component originating from an external script, or when identifying items in a dynamic list.

    Under the hood, TComponent maintains a single, global WeakMap that binds root DOM elements to their component instances.

    • Type-safe: Calling ComponentClass.from() automatically checks instanceof before returning. This ensures you never accidentally invoke methods on the wrong component type, and TypeScript correctly infers the return type as ComponentClass | undefined.
    • Memory-safe: Because it relies on a WeakMap, when a DOM element is permanently removed and garbage-collected, its component reference is automatically cleared without causing memory leaks.
    import TComponent from '@haiix/tcomponent';

    class AlertBox extends TComponent<HTMLDivElement> {
    static template = /* HTML */ `<div class="alert">Warning!</div>`;

    dismiss() {
    this.destroy();
    }
    }

    // Somewhere else in your application...
    // 1. You query an element from the DOM
    const el = document.querySelector('.alert');

    // 2. Retrieve the component instance directly from the DOM element
    // 'alert' is strictly typed as AlertBox | undefined
    const alert = AlertBox.from(el);

    if (alert) {
    // 3. Safely call component methods
    alert.dismiss();
    }

    Instead of defining components statically in the template using uses, you will often need to create child components dynamically—such as when rendering a list of items fetched from an API, or opening a modal dialog.

    When manually instantiating a child component, it is highly recommended to pass down the parent.

    • Passing parent: this: Links the child to the parent's lifecycle (So if the parent is destroyed, the dynamically created child will automatically clean up its own event listeners.) and Error Boundary (so if the child throws an error, the parent's onerror catches it).
    import TComponent, { ComponentParams } from '@haiix/tcomponent';

    class UserCard extends TComponent<HTMLDivElement> {
    static template = /* HTML */ `
    <div class="card">
    <h3 id="name"></h3>
    <button onclick="handleDelete">Delete</button>
    </div>
    `;

    constructor(params: ComponentParams & { userName: string }) {
    super(params);
    const nameEl = this.getById('name', HTMLHeadingElement);
    nameEl.textContent = params.userName;
    }

    handleDelete() {
    // Manually destroy this specific instance and remove it from the DOM
    this.destroy();
    }
    }

    class UserListApp extends TComponent<HTMLDivElement> {
    static template = /* HTML */ `
    <div>
    <h2>Users</h2>
    <!-- We will inject dynamically created components here -->
    <div id="list-container"></div>

    <button onclick="loadUsers">Load Users</button>
    </div>
    `;

    async loadUsers() {
    const container = this.getById('list-container', HTMLDivElement);

    // [Important] Memory Leak Prevention
    // Before rendering new children, you must destroy existing child components.
    // Simply calling `container.innerHTML = ''` removes elements from the DOM,
    // but this may leave event listeners active and references retained via the parent's AbortSignal,
    // which can lead to memory leaks if not properly cleaned up.
    for (const childElement of Array.from(container.children)) {
    const card = UserCard.from(childElement);
    if (card) {
    card.destroy();
    }
    }

    const users = ['Alice', 'Bob', 'Charlie'];

    for (const name of users) {
    // 1. Manually instantiate the child component.
    // 2. Pass the parent. Its lifecycle and error boundary are automatically linked.
    const card = new UserCard({ userName: name, parent: this });

    // 3. Manually append the child's element to the DOM
    container.appendChild(card.element);
    }
    }
    }

    TComponent is intentionally unopinionated about how components communicate with each other. It does not force you into a specific prop-drilling or event-emitting system. You can choose the approach that best fits your project's architecture.

    Here are a few standard patterns you can use:

    For child-to-parent communication, you can dispatch standard HTML CustomEvents from the child's root element. The parent can listen to these natively.

    class Child extends TComponent {
    static template = /* HTML */ `
    <button onclick="notifyParent">Click</button>
    `;

    notifyParent() {
    // Dispatch a standard CustomEvent bubbling up the DOM tree
    this.element.dispatchEvent(
    new CustomEvent('child-clicked', {
    detail: { value: 123 },
    bubbles: true,
    }),
    );
    }
    }

    You can pass callback functions down to children using manual assignment or by calling public methods on the child component instance.

    class Parent extends TComponent {
    static uses = { Child };
    static template = /* HTML */ `<child id="my-child"></child>`;

    constructor(params: ComponentParams) {
    super(params);
    const child = this.getById('my-child', Child);

    // Explicitly assign a callback to the child instance
    child.onAction = (data) => console.log('Data from child:', data);
    }
    }

    Because TComponent components are just standard ES6 classes managing DOM nodes, they integrate well with external state managers (like Redux, Zustand, or simple Observables/EventEmitters). You can subscribe to external state within your component's constructor and explicitly update the DOM when state changes.


    If an event listener throws an error (or a Promise rejects), TComponent catches it and calls the onerror method. If the current component does not override onerror or explicitly throws the error again, the error propagates to the parent component.

    This allows you to create top-level "Error Boundary" components to handle UI failures gracefully.

    class Child extends TComponent {
    static template = /* HTML */ `<button onclick="doAction">Throw</button>`;

    async doAction() {
    throw new Error('Something went wrong in the child!');
    }
    }

    class Parent extends TComponent {
    static uses = { Child };
    static template = /* HTML */ `<div><child></child></div>`;

    onerror(error: unknown) {
    console.error('Caught in Parent Error Boundary:', error);
    }
    }

    In vanilla JavaScript, Event Delegation is a powerful pattern where you attach a single event listener to a parent element to handle events triggered by its many children. This drastically reduces memory usage compared to attaching individual onclick listeners to hundreds of child components.

    Because TComponent provides the static from(element) method, implementing type-safe event delegation is highly intuitive.

    Instead of attaching an onclick listener to every single <task-item>, you can attach one listener to the parent <ul class="task-list"> and use event.target to find the associated child component.

    import TComponent, {
    ComponentParams,
    kebabKeys,
    applyParams,
    } from '@haiix/tcomponent';

    class TaskItem extends TComponent<HTMLLIElement> {
    static template = /* HTML */ `<li class="task-item"></li>`;

    constructor(params: ComponentParams) {
    super(params);
    applyParams(this, this.element, params);
    }

    // Best Practice: State is derived directly from the DOM
    get isCompleted(): boolean {
    return this.element.style.textDecoration === 'line-through';
    }

    set isCompleted(value: boolean) {
    this.element.style.textDecoration = value ? 'line-through' : 'none';
    }

    toggle() {
    this.isCompleted = !this.isCompleted;
    }
    }

    class TaskList extends TComponent<HTMLUListElement> {
    static uses = kebabKeys({ TaskItem });

    // Attach a single event listener to the parent <ul>
    static template = /* HTML */ `
    <ul class="task-list" onclick="handleListClick">
    <task-item>Task1</task-item>
    <task-item>Task2</task-item>
    <task-item>Task3</task-item>
    </ul>
    `;

    handleListClick(event: MouseEvent) {
    // 1. Find the closest <li> element that was clicked
    const target = event.target as Element;
    const liElement = target.closest('li.task-item');

    // 2. Retrieve the component instance from the DOM element
    // 'task' is inferred as TaskItem | undefined
    const task = TaskItem.from(liElement);

    if (task) {
    // 3. Explicitly call the component's method
    task.toggle();
    }
    }
    }