DRY Up with Inheritance

Musango WopeMusango Wope
5 min read

For one of my many side project escapades (unfinished), I built a web component library for rendering 3D elements using Three.js. Each component in the library encapsulates a unique 3D scene or model and can be reused in any HTML page.

While building out the components, I noticed a pattern of repeated code across different components. Most of them shared common functionality, such as:

  • Setting up the lights in the 3D scene

  • Creating and configuring the camera and controls

  • Handling lifecycle callbacks (connectedCallback and disconnectedCallback)

  • Managing the intersection observer for visibility detection

To illustrate this, here’s a simplified skeleton of two such components, PuShiftingShapes and PuGuyWorkingOne:

class PuShiftingShapes extends HTMLElement {
    setupLights() {
        /** Sets up lighting for the 3D scene */
    }

    setupScene() {
        /** Initializes the 3D scene */
    }

    async onIntersect() {
        /** Called when element becomes visible in viewport */
    }

    setupIntersectionObserver() {
        /** Sets up observer to detect when element enters viewport */
    }

    onAnimate(data: { deltaTime: number }) {
        /** Frame-by-frame animation callback */
    }

    connectedCallback() {
        // Called when the component is added to the DOM
    }

    disconnectedCallback() {
        // Cleanup when component is removed from the DOM
    }

    // Custom logic ...
}
class PuGuyWorkingOne extends HTMLElement {
    setupLights() { /* duplicated code */ }
    setupScene() { /* duplicated code */ }
    async onIntersect() { /* duplicated code */ }
    setupIntersectionObserver() { /* duplicated code */ }
    onAnimate() { /* duplicated code */ }
    connectedCallback() { /* duplicated code */ }
    disconnectedCallback() { /* duplicated code */ }

    // Custom logic ...
}

As you can see, both components contain nearly identical boilerplate code, which violates the DRY (Don't Repeat Yourself) principle and makes maintenance harder in the long run.


Applying Inheritance for Cleaner Code

To eliminate redundancy, I created a base class called PuBaseElement that encapsulates all the shared logic and lifecycle behavior.

class PuBaseElement extends HTMLElement {
    setupLights() { /* shared lighting setup */ }
    setupScene() { /* shared scene/camera/control setup */ }
    async onIntersect() { /* shared intersection behavior */ }
    setupIntersectionObserver() { /* shared observer logic */ }
    onAnimate() { /* common animation callback */ }
    connectedCallback() { /* mount hook */ }
    disconnectedCallback() { /* unmount hook */ }
}

Now, individual components like PuShiftingShapes and PuGuyWorkingOne can extend PuBaseElement and focus only on their unique logic. This leads to:

  • Code reuse across all components

  • Better scalability as the shared functionality evolves

  • Cleaner separation of concerns, allowing each component to be more maintainable

Refactored: PuShiftingShapes

import { PuBaseElement } from "../../base-element.ts";
import { ShiftingCubesModel } from "../../3d-models/abstracts/shifting-cubes-model.ts";
import {
  AbstractShapeModel,
  GeometryShape,
} from "../../3d-models/abstracts/abstract-shape-model/abstract-shape-model.ts";

/**
 * Custom element that renders shifting cubes and a torus shape in 3D
 * Example:
 * <pu-shifting-shapes shifting-cubes-color="#ff5733" torus-color="#33a8ff">
 * </pu-shifting-shapes>
 */
class PuShiftingShapes extends PuBaseElement {
  constructor() {
    super();

    const shiftingCubesColor = this.getAttribute("shifting-cubes-color");
    const torusColor = this.getAttribute("torus-color");
    const textureUrl = this.getAttribute("texture-url") || "";

    const shiftingCubesModel = new ShiftingCubesModel({
      scene: this.scene,
      scale: 0.25,
      materialColor: shiftingCubesColor,
    });

    const torusModel = new AbstractShapeModel({
      scene: this.scene,
      geometryType: GeometryShape.Torus,
      scale: 0.3,
      materialColor: torusColor,
      textureUrl: textureUrl,
    });

    this.models = [shiftingCubesModel, torusModel];
    this.attachShadow({ mode: "open" });

    this.setCubePosition();
  }

  setCubePosition() {
    window.setTimeout(() => {
      const [_, torusModel] = this.models;
      torusModel?.object3Ds?.abstractShape?.translateX(-1);
      torusModel?.object3Ds?.abstractShape?.translateY(0.4);
    }, 50);
  }
}

customElements.define("pu-shifting-shapes", PuShiftingShapes);

Refactored: PuGuyWorkingOne

import { PuBaseElement } from "../../base-element.ts";
import { GuyWorkingOneModel } from "../../3d-models/avatars/guy-working-one-model.ts";

/**
 * Custom element that renders a 3D model of a working guy avatar.
 * Extends the base element to handle shared 3D functionality.
 */
class PuGuyWorkingOne extends PuBaseElement {
  constructor() {
    super();

    const modelUrl = this.getAttribute("model-url") || '"/models/guy-working-one.glb"';

    const model = new GuyWorkingOneModel({
      scene: this.scene,
      modelUrl: modelUrl,
      scale: 0.2,
    });

    this.models = [model];
  }
}

customElements.define("pu-guy-working-one", PuGuyWorkingOne);

Key Concepts Illustrated

Inheritance

By extending PuBaseElement, we leverage JavaScript’s native class inheritance to share functionality and minimize duplication across components.

Code Reuse

Centralizing the logic for common operations like setting up the scene, lights, and lifecycle hooks prevents copy-pasting and makes it easier to update behavior in one place.

Scalability

As the component library grows, new 3D elements can be added with minimal boilerplate. Developers can focus on their unique logic while inheriting a robust, consistent foundation.

Maintainability

Encapsulating core logic in a base class improves readability and reduces the risk of bugs when changes are needed. Fixes and improvements only need to be made once in the base class.

Extensibility

Because components override only the parts they need (e.g., models or positioning), it’s easy to compose new behaviors or override base functionality when needed.


Demo of custom elements

Here’s a short demo showing how these custom elements render and animate in the browser.

Conclusion

This refactoring showcases the power of object-oriented design in front-end architecture. By leveraging inheritance and thoughtful component design, I built a more scalable, maintainable, and developer-friendly foundation for a reusable 3D web component library with Three.js.

This approach not only improves the developer experience but also sets the stage for more complex, performant, and modular 3D UI components in the future.

That said, inheritance is not a one-size-fits-all solution. It shines in scenarios where multiple components share substantial logic, as in this case. But for other situations—especially when components diverge significantly in structure or behavior—composition or mixins might be more appropriate. It's all about choosing the right tool for the job.

0
Subscribe to my newsletter

Read articles from Musango Wope directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Musango Wope
Musango Wope