A TypeScript Base Component for Building Web Components with Pure Vanilla JavaScript
You may discover that you need far less tooling than you thought.
A TypeScript Base Component for Building Web Components with Pure Vanilla JavaScript
You may discover that you need far less tooling than you thought.
Photo by Van Tay Media on Unsplash
Introduction
Modern frontend development often feels like a choice between power and simplicity. Frameworks offer structure, tooling, and conventions — but at the cost of complexity, build steps, and long-term dependencies. On the other hand, the Web Platform itself has quietly matured: Custom Elements, Shadow DOM, and ES modules are now well supported and production-ready.
In this article, I’ll introduce a small but solid TypeScript base component class designed to help you build custom Web Components using plain vanilla JavaScript, with zero external dependencies. The goal is not to replace frameworks, but to provide a clean, predictable foundation for creating reusable UI components when you want full control, minimal overhead, and long-term stability.
We’ll focus on a pragmatic approach: a base class that handles the boring parts — lifecycle hooks, rendering, state updates, and attribute observation — while staying transparent and easy to extend. If you enjoy writing code that maps closely to the browser’s native APIs and want a lightweight alternative to modern frameworks, this is for you.
What is BaseComponent
BaseComponent is a TypeScript base class for creating Web Components (custom elements) with:
- responsive properties synchronized with HTML attributes
- automatic rendering when properties change
- microtask rendering scheduling to optimize performance (batching multiple updates)
- Shadow DOM to encapsulate markup and style
- Optional hook to register listeners using event delegation
All child components extend BaseComponent and implement at least the render() method .
Installation and build
cd base-component
npm install
npm run build
How to use BaseComponent
- Nel browser (bundle UMD)
After the build you can include the UMD bundle in an HTML page:
<script src="dist/index.js"></script>
<script>
const { BaseComponent } = window.BaseComponent; // esport globale UMD
</script>
- Eat module ES
In a bundled project (webpack, Vite, etc.) or in an environment that supports ES modules:
import BaseComponent from '@test/base-component';
ComponentBase API
customElements.define('my-tag', MyComponent)
BaseComponent extends HTMLElement, so a child component:
- si definisce con customElements.define(‘my-tag’, MyComponent)`
- normally participates in the lifecycle of custom elements
Main methods
protected abstract render(): void
It must be implemented in the child component. It is called:
- once in connectedCallback()
- whenever a reactive property changes
protected setupEventListeners(): void
(Optional) Hook called after the first render() in connectedCallback(). Override it to register listeners (e.g., on this.shadowRoot ), often using setupEventDelegation . If your component doesn't have custom events, you can ignore it.
protected defineReactiveProperties(definitions)
Shortcut to define multiple reactive properties at once. Internally, call defineReactiveProperty for each entry.
protected emit<T>(name: string, detail?: T, options?: CustomEventInit)
Emits a CustomEvent from the component (this):
this.emit('counter:changed', { value: this.counter });
protected setupEventDelegation(actions, eventType?, container?)
Set up a delegate listener (e.g. on shadowRoot) and route events based on a data-{eventType} attribute.
Mapping examples:
- eventType = ‘click’ → legge
data-click - eventType = 'change' → add
data-change
Reactive properties
A reactive property:
- has an internal value held in _state
- can be associated with an attribute (e.g. count ↔ count=1)
- when changes: update the attribute (if configured), schedule a new
render()
constructor() {
super();
this.defineReactiveProperties({
count: {
attribute: 'count',
initial: 0,
fromAttribute: (v) => Number(v),
},
step: {
attribute: 'step',
initial: 1,
fromAttribute: (v) => Number(v),
},
disabled: {
attribute: 'disabled',
initial: false,
fromAttribute: (v) => v !== null,
toAttribute: (v) => (v ? '' : null),
},
});
}
Examples of use
Uso nel browser (UMD)
<script src="dist/index.js"></script>
<script>
const { BaseComponent } = window.BaseComponent;
class MyCounter extends BaseComponent {
constructor() {
super();
this.defineReactiveProperties({
count: {
attribute: 'count',
initial: 0,
fromAttribute: (v) => Number(v),
},
});
}
increment() {
this.count = this.count + 1;
this.emit('counter:changed', { value: this.count });
}
render() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
<button onclick="this.getRootNode().host.increment()">
Count: ${this.count}
</button>`
}
}
customElements.define('my-counter', MyCounter);
< body >
<! - count attribute initializes the reactive property →
< my-counter count = "5" > </ my-counter >
</ body >
I use the ES module and setupEventDelegation.
import BaseComponent from '@test/base-component';
class MyCounter extends BaseComponent {
declare count: number;
declare step: number;
constructor() {
super();
this.defineReactiveProperties({
count: {
attribute: 'count',
initial: 0,
fromAttribute: (v) => Number(v),
},
step: {
attribute: 'step',
initial: 1,
fromAttribute: (v) => Number(v),
},
});
}
increment(): void {
this.count = this.count + this.step;
this.emit('counter:changed', { value: this.count });
}
decrement(): void {
this.count = this.count - this.step;
this.emit('counter:changed', { value: this.count });
}
protected render(): void {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
<style>
button {
margin-right: 0.5rem;
}
</style>
<p>Counter: ${this.count}</p>
<button data-click="inc">+</button>
<button data-click="dec">−</button>
`;
}
protected setupEventListeners(): void {
this.setupEventDelegation({
inc: () => this.increment(),
dec: () => this.decrement(),
},
'click', this.shadowRoot ?? this);
}
}
customElements.define('my-counter', MyCounter);
In this example I complete the child component:
- defines reactive properties (
count,step) - implements
render()to update the Shadow DOM - usa
setupEventDelegationper mapparedata-click=”inc|dec”suincrement()/decrement() - emits a
counter:changedevent whenever the value changes.
BaseComponent.ts
If you’re curious to experiment, start small: wrap one UI element, ship it, You may discover that you need far less tooling than you thought.
/**
* Type mapping for attribute names to property names
* Used to track which attributes correspond to which reactive properties
*/
type AttributeToPropertyMap = Record<string, string>;
/**
* Configuration options for defining a reactive property
* @template T - The type of the property value
*/
interface ReactivePropertyOptions<T = unknown> {
/** The HTML attribute name that maps to this property (defaults to property name) */
attribute?: string;
/** Initial value for the property */
initial?: T;
/** Function to convert attribute string value to property type */
fromAttribute?: (value: string | T) => T;
/** Function to convert property value to attribute string (returns null to remove attribute) */
toAttribute?: (value: T) => string | null;
}
/**
* Base Component Class
* Abstract base class for creating Web Components with reactive properties.
* Extends HTMLElement to create custom elements that can be used in HTML.
*
* Features:
* - Reactive properties that sync with HTML attributes
* - Automatic rendering when properties change
* - Shadow DOM support for style encapsulation
* - Type-safe property definitions
*/
export abstract class BaseComponent extends HTMLElement {
/** Enable development diagnostics */
static DEV = true;
/**
* Internal reactive state storage
* Stores the current values of all reactive properties
*/
protected _state: Record<string, unknown>;
/**
* Attribute to property name mapping
* Maps HTML attribute names to their corresponding property names
*/
protected _attrToProp: AttributeToPropertyMap;
/**
* Observed attributes storage (per class, not per instance)
* Static property that stores which attributes should be observed for changes
*/
static _observedAttributes?: string[];
/**
* Returns the list of attributes that should be observed for changes
* Required by the Custom Elements API
* @returns Array of attribute names to observe
*/
static get observedAttributes(): string[] {
return this._observedAttributes ?? [];
}
/**
* Constructor for BaseComponent
* Initializes the component with Shadow DOM and reactive state
*/
constructor() {
super();
// Attach Shadow DOM with 'open' mode to allow external access
this.attachShadow({ mode: 'open' });
// Initialize state and attribute mapping as empty objects
this._state = Object.create(null);
this._attrToProp = Object.create(null);
}
/**
* Define a single reactive property
* Creates a property that automatically syncs with HTML attributes and triggers re-renders
*
* @template T - The type of the property value
* @param name - The name of the property to define
* @param options - Configuration options for the reactive property
*
* @example
* ```typescript
* this.defineReactiveProperty('count', {
* attribute: 'count',
* initial: 0,
* fromAttribute: (v) => Number(v),
* toAttribute: (v) => String(v)
* });
* ```
*/
protected defineReactiveProperty<T>(
name: string,
options: ReactivePropertyOptions<T> = {}
): void {
// Extract options with defaults
const {
attribute = name, // Default attribute name to property name
initial, // Initial value (optional)
fromAttribute = (v => v as T), // Default: no conversion
toAttribute = (v => String(v)) // Default: convert to string
} = options;
// Initialize the property value in state
//this._state[name] = initial;
// Initialize the property value in state
// Check if attribute already exists, if so use it, otherwise use initial
const existingAttr = attribute ? this.getAttribute(attribute) : null;
this._state[name] = existingAttr !== null
? fromAttribute(existingAttr)
: initial;
// If attribute mapping is specified, set up attribute observation
if (attribute) {
// Map attribute name to property name
this._attrToProp[attribute] = name;
// Get the constructor (class) to access static properties
const ctor = this.constructor as typeof BaseComponent;
// Initialize observed attributes array if it doesn't exist
ctor._observedAttributes ??= [];
// Add attribute to observed list if not already present
if (!ctor._observedAttributes.includes(attribute)) {
ctor._observedAttributes.push(attribute);
}
}
// Check if property already exists as a data property and delete it first
// This ensures the accessor property (getter/setter) is properly defined
const existingDescriptor = Object.getOwnPropertyDescriptor(this, name);
if (existingDescriptor && 'value' in existingDescriptor && !('get' in existingDescriptor)) {
// Delete existing data property to make room for accessor property
delete (this as any)[name];
}
// Define the property with getter and setter
Object.defineProperty(this, name, {
configurable: true, // Allow property to be deleted/redefined (needed for upgradeProperty)
enumerable: true, // Property appears in enumeration
/**
* Getter: Returns the current value from state
*/
get: (): T => this._state[name] as T,
/**
* Setter: Updates the property value and syncs with attribute
* @param value - The new value (can be string from attribute or typed value)
*/
set: (value: T | string) => {
// Convert value using fromAttribute function
const newValue = fromAttribute(value);
// Get current value for comparison
const oldValue = this._state[name] as T;
// Centralized guard: prevent unnecessary updates
// Uses Object.is() for NaN-safe and -0/+0 safe comparison
if (Object.is(newValue, oldValue)) return;
// Update state with new value
this._state[name] = newValue;
// Sync with HTML attribute if attribute mapping exists
if (attribute) {
// Convert property value to attribute string
const attrValue = toAttribute(newValue);
// Get current attribute value
const current = this.getAttribute(attribute);
// Update or remove attribute as needed
if (attrValue === null) {
// Remove attribute if converter returns null
if (current !== null) this.removeAttribute(attribute);
} else if (current !== attrValue) {
// Update attribute if value changed
this.setAttribute(attribute, attrValue);
}
}
// Schedule a re-render after property change
this.scheduleRender();
}
});
}
/**
* Upgrade a pre-defined instance property to use the reactive
* getter/setter defined on the prototype.
*
* Why this is needed:
* - When an element is created from HTML, attributes exist BEFORE
* the custom element constructor runs.
* - User code (or the browser) may set a property on the instance
* before the reactive property descriptor is defined.
* - In that case, the property becomes an "own property" on the
* instance and bypasses the reactive setter.
** This method:
* 1. Detects if the property already exists directly on the instance
* 2. Temporarily stores its value
* 3. Deletes the instance property
* 4. Re-applies the value so it flows through the reactive setter
*
*/
protected upgradeProperty(prop: string) {
// Check if property exists as an own property (data property)
// This can happen if the property was set before the descriptor was defined
const descriptor = Object.getOwnPropertyDescriptor(this, prop);
// Only upgrade if it's a data property (has value/writable), not an accessor property (has get/set)
if (descriptor && 'value' in descriptor && !('get' in descriptor)) {
const value = (this as any)[prop];
if ((this.constructor as typeof BaseComponent).DEV) {
console.warn(
`[${this.tagName.toLowerCase()}] Property "${prop}" ` +
`was set before the component was upgraded. ` +
`The value has been preserved and re-applied.`,
{
element: this,
property: prop,
value
}
);
}
// Delete the data property so the accessor property (getter/setter) can be used
delete (this as any)[prop];
// Re-apply the value through the setter (which should now exist)
// This ensures the value flows through the reactive system
(this as any)[prop] = value;
}
}
/**
* Define multiple reactive properties at once
* Convenience method to define multiple properties in a single call
*
* @param definitions - Object mapping property names to their configuration options
*
* @example
* ```typescript
* this.defineReactiveProperties({
* count: { attribute: 'count', initial: 0 },
* label: { attribute: 'label', initial: '' }
* });
* ```
*/
protected defineReactiveProperties(
definitions: Record<string, ReactivePropertyOptions>
): void {
// Iterate over each property definition and create it
for (const [name, options] of Object.entries(definitions)) {
this.defineReactiveProperty(name, options);
this.upgradeProperty(name);
}
}
/**
* Attribute → property delegation
* Called by the browser when an observed attribute changes
* This is part of the Custom Elements API lifecycle
*
* @param name - The name of the attribute that changed
* @param _oldValue - The previous attribute value (unused, prefixed with _)
* @param newValue - The new attribute value
*
* When an attribute changes, this method updates the corresponding property,
* which will trigger the property setter and schedule a re-render
*/
attributeChangedCallback(
name: string,
_oldValue: string | null,
newValue: string | null
): void {
// Look up the property name for this attribute
const prop = this._attrToProp[name];
if (prop) {
// Check if this attribute change was caused by a property setter
// (to avoid infinite loops). If the current attribute value matches
// what we're setting, skip updating the property to avoid loop.
const currentAttr = this.getAttribute(name);
if (currentAttr === newValue) {
// Attribute is already in sync, likely set by property setter
// Just schedule a render to ensure UI is updated
this.scheduleRender();
return;
}
// Set the property value, which will trigger the setter
// This ensures attribute changes flow through the same reactive system
(this as any)[prop] = newValue;
}
}
/**
* Render scheduler (single source of rendering)
* Schedules a render to occur in the next microtask
* Uses queueMicrotask to batch multiple property changes into a single render
* This prevents unnecessary re-renders when multiple properties change quickly
*/
protected scheduleRender(): void {
// Prevent duplicate renders by checking if a render is already scheduled
if ((this as any)._renderScheduled) {
return;
}
// Mark that render is scheduled
(this as any)._renderScheduled = true;
// Queue render in next microtask to batch updates
queueMicrotask(() => {
this.render();
// Clear the flag after render completes
(this as any)._renderScheduled = false;
});
}
/**
* Emit a CustomEvent from the component
*/
protected emit<T = unknown>(
name: string,
detail?: T,
options: CustomEventInit = {}
): boolean {
const event = new CustomEvent<T>(name, {
detail,
bubbles: true,
composed: true,
...options
});
return this.dispatchEvent(event);
}
/**
* Called when the element is inserted into the DOM
* This is part of the Custom Elements API lifecycle
* Performs the initial render when the component is connected
*/
connectedCallback(): void {
// Perform initial render when component is connected to DOM
// This ensures the component is rendered after it's in the DOM
// and all attributes have been processed
this.render();
// After initial render, allow subclasses to attach their event
// listeners (if they need any). The default implementation is a
// no-op, so overriding this method is optional.
this.setupEventListeners();
}
/**
* Abstract render method - must be implemented by subclasses
* This method is called whenever reactive properties change
* Subclasses should implement this to update the component's DOM
*
* @example
* ```typescript
* protected render(): void {
* this.shadowRoot!.innerHTML = `<div>Count: ${this.count}</div>`;
* }
* ```
*/
protected abstract render(): void;
/**
* Helper for setting up event delegation on the component.
*
* It listens for events (by default, `"click"`) on a container
* (by default, `shadowRoot` if present, otherwise the host element)
* and dispatches them to handler functions based on the target
* element’s `data-{eventType}` attribute.
*
* For example:
* - `eventType: "click"` → uses `data-click`
* - `eventType: "change"` → uses `data-change`
*
* @example
* ```ts
* protected setupEventListeners(): void {
* this.setupEventDelegation(
* {
* inc: () => this.increment(),
* dec: () => this.decrement(),
* },
* 'click', // will look for elements with data-click="inc" / data-click="dec"
* );
* }
* ```
*
* @param actions - Map where keys are `data-{eventType}` values and values
* are handler functions invoked when an element with that
* attribute is interacted with.
* @param eventType - DOM event type to listen for (default: `"click"`).
* @param container - Node on which to attach the delegated listener.
* Defaults to `this.shadowRoot` if present, otherwise
* the custom element instance (`this`).
*/
protected setupEventDelegation(
actions: Record<string, (event: Event, target: HTMLElement) => void>,
eventType: string = 'click',
container: HTMLElement | ShadowRoot | null = this.shadowRoot ?? this
): void {
if (!container) return;
// Derive the data-attribute name from the event type, so:
// click -> data-click
// change -> data-change
// input -> data-input
const attrName = `data-${eventType}`;
const handler = (event: Event) => {
const target = (event.target as HTMLElement | null)?.closest(
`[${attrName}]`
) as HTMLElement | null;
if (!target) return;
const action = target.getAttribute(attrName);
if (!action) return;
const fn = actions[action];
if (typeof fn === 'function') {
fn.call(this, event, target);
}
};
container.addEventListener(eventType, handler);
}
/**
* Optional hook for setting up event listeners after the component
* has been rendered and connected to the DOM.
*
* Override this in child components that need to register event
* listeners. Components that don't need custom listeners can ignore
* this hook entirely.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected setupEventListeners(): void {}
}
Thanks for reading — and if you build something interesting with this approach, feel free to share it. Sometimes the simplest abstractions are the ones that last the longest.
메타데이터
- post_id
- 08b65111bb37
- slug
- a-typescript-base-component-for-building-web-components-with-pure-vanilla-javascript-08b65111bb37
- url
- https://medium.com/@nxcode.hub/a-typescript-base-component-for-building-web-components-with-pure-vanilla-javascript-08b65111bb37
- canonical_url
- https://medium.com/@nxcode.hub/a-typescript-base-component-for-building-web-components-with-pure-vanilla-javascript-08b65111bb37
- author_url
- https://medium.com/@nxcode.hub
- status
- ok
- fetched_at
- 2026-07-15 16:53:31