DRY Up with Inheritance


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
anddisconnectedCallback
)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.
Subscribe to my newsletter
Read articles from Musango Wope directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
