Back to home
dc

Daily Chronicle

Frontend

Web Components: Building Maintainable Apps Without Frameworks

Discover how Web Components provide maintainability and extensibility without frameworks. Learn to build modern web apps using native browser APIs with smaller bundles and zero dependencies.

January 12, 2025

Web Components: Building Maintainable Apps Without Frameworks

Introduction

If you've been following web development trends, you've probably noticed something: every few years, a new framework emerges as the "solution" to all our problems. React replaced Angular, Vue simplified React, and now we have Svelte, Solid, and countless others competing for attention.

But here's the uncomfortable truth: we might be overcomplicating things.

What if I told you that modern browsers already have everything you need to build maintainable, extensible web applications? No npm install, no build tools, no framework lock-in. Just standard Web Components that work everywhere.

In this post, I'll show you why frameworks are often overused and how Web Components provide a simpler, more sustainable path forward.

What Are Web Components?

Web Components are a suite of browser-native APIs that let you create reusable, encapsulated HTML elements. They consist of three main technologies:

1. Custom Elements

Custom Elements let you define your own HTML tags with custom behavior. Once registered, you can use them like any native HTML element.

2. Shadow DOM

Shadow DOM provides encapsulation for your component's styles and markup. It creates a separate DOM tree that won't leak styles in or out, solving the CSS specificity nightmare.

graph TD
    A[Document DOM] --> B[Light DOM]
    A --> C[Shadow DOM Boundary]
    C --> D[Shadow Root]
    D --> E[Encapsulated Styles]
    D --> F[Encapsulated Markup]
    style C fill:#f9f,stroke:#333,stroke-width:3px
    style D fill:#bbf,stroke:#333,stroke-width:2px

3. HTML Templates

The <template> element lets you declare fragments of HTML that won't be rendered immediately but can be cloned and used by your components.

Together, these APIs give you everything frameworks promise: reusable components, style encapsulation, and lifecycle management—all built into the browser.

Why Frameworks Are Overused

Native Browser Support Means Zero Dependencies

When you use React, Vue, or Angular, you're shipping their entire runtime to your users. React alone is about 40KB minified and gzipped. For a simple application, that's absurd overhead.

Web Components? Zero bytes. The browser already supports them. No library to download, parse, or execute. Your components run directly on browser APIs that are heavily optimized at the engine level.

Smaller Bundle Sizes

Let's talk numbers. A typical React application might ship:

  • React core: ~40KB
  • React DOM: ~130KB
  • State management (Redux/Zustand): ~10-20KB
  • Routing library: ~20KB

That's roughly 200KB before you write a single line of application code.

A Web Components application? Just your component code. A moderately complex component might be 5-10KB. Even with 20 components, you're still under 200KB—and that's your entire application, not just the framework.

Standards-Based and Future-Proof

Here's a question: Will your React code work in 10 years? Maybe. Will browsers still support Web Components in 10 years? Absolutely.

Web Components are built on web standards maintained by the W3C and WHATWG. They're not controlled by a single company or subject to breaking changes every major version. Code you write today will work without modification for decades.

Think about it: HTML from 1995 still renders correctly. That's the power of standards.

No Build Tools Required

Modern framework development requires:

  • Webpack/Vite/Parcel configuration
  • Babel for transpilation
  • JSX/template compilation
  • Source maps generation
  • Hot module replacement setup

Web Components need none of this. Write your component, save the file, refresh the browser. That's it. You can add build tools later if needed, but they're optional, not mandatory.

Real-World Patterns with Web Components

Now let's address the elephant in the room: "But how do I handle state management and routing without a framework?"

State Management with a Lightweight Observable Pattern

Here's a simple but powerful observable pattern for state management:

// state-manager.js
class StateManager {
  constructor(initialState = {}) {
    this.state = initialState;
    this.listeners = new Set();
  }

  getState() {
    return this.state;
  }

  setState(updates) {
    this.state = { ...this.state, ...updates };
    this.notify();
  }

  subscribe(listener) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  notify() {
    this.listeners.forEach(listener => listener(this.state));
  }
}

// Create a global store (or multiple stores for different domains)
export const todoStore = new StateManager({
  todos: [],
  filter: 'all'
});

This is simpler than Redux, more explicit than React Context, and works with any component—Web Component or not.

sequenceDiagram
    participant C as Component
    participant S as Store
    participant L as Listeners
    
    C->>S: setState(updates)
    S->>S: Update internal state
    S->>L: notify()
    L->>C: Trigger re-render
    C->>S: getState()
    S-->>C: Return current state

Building a Complete Todo Component

Let's build a real-world component that demonstrates these concepts:

// todo-item.js
import { todoStore } from './state-manager.js';

class TodoItem extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  static get observedAttributes() {
    return ['todo-id'];
  }

  connectedCallback() {
    this.todoId = this.getAttribute('todo-id');
    this.unsubscribe = todoStore.subscribe(state => {
      this.todo = state.todos.find(t => t.id === this.todoId);
      this.render();
    });
    
    // Initial render
    this.todo = todoStore.getState().todos.find(t => t.id === this.todoId);
    this.render();
  }

  disconnectedCallback() {
    if (this.unsubscribe) this.unsubscribe();
  }

  render() {
    if (!this.todo) return;

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          padding: 12px;
          border-bottom: 1px solid #eee;
        }
        
        .todo-item {
          display: flex;
          align-items: center;
          gap: 12px;
        }
        
        .todo-item.completed .todo-text {
          text-decoration: line-through;
          color: #888;
        }
        
        .checkbox {
          width: 20px;
          height: 20px;
          cursor: pointer;
        }
        
        .todo-text {
          flex: 1;
          font-size: 16px;
        }
        
        .delete-btn {
          background: #ff4444;
          color: white;
          border: none;
          padding: 6px 12px;
          border-radius: 4px;
          cursor: pointer;
        }
        
        .delete-btn:hover {
          background: #cc0000;
        }
      </style>
      
      <div class="todo-item ${this.todo.completed ? 'completed' : ''}">
        <input 
          type="checkbox" 
          class="checkbox"
          ${this.todo.completed ? 'checked' : ''}
        />
        <span class="todo-text">${this.todo.text}</span>
        <button class="delete-btn">Delete</button>
      </div>
    `;

    this.attachEventListeners();
  }

  attachEventListeners() {
    const checkbox = this.shadowRoot.querySelector('.checkbox');
    const deleteBtn = this.shadowRoot.querySelector('.delete-btn');

    checkbox.addEventListener('change', () => {
      const state = todoStore.getState();
      const updatedTodos = state.todos.map(t =>
        t.id === this.todoId ? { ...t, completed: !t.completed } : t
      );
      todoStore.setState({ todos: updatedTodos });
    });

    deleteBtn.addEventListener('click', () => {
      const state = todoStore.getState();
      const updatedTodos = state.todos.filter(t => t.id !== this.todoId);
      todoStore.setState({ todos: updatedTodos });
    });
  }
}

customElements.define('todo-item', TodoItem);

Usage is simple:

<todo-item todo-id="1"></todo-item>
<todo-item todo-id="2"></todo-item>

Routing Without a Framework

For routing, you can use the native History API:

class Router {
  constructor(routes) {
    this.routes = routes;
    window.addEventListener('popstate', () => this.handleRoute());
    document.addEventListener('click', e => {
      if (e.target.matches('[data-link]')) {
        e.preventDefault();
        this.navigate(e.target.href);
      }
    });
  }

  navigate(url) {
    history.pushState(null, null, url);
    this.handleRoute();
  }

  handleRoute() {
    const path = window.location.pathname;
    const route = this.routes[path] || this.routes['/404'];
    route();
  }
}

// Usage
const router = new Router({
  '/': () => document.querySelector('#app').innerHTML = '<home-page></home-page>',
  '/todos': () => document.querySelector('#app').innerHTML = '<todo-page></todo-page>',
  '/404': () => document.querySelector('#app').innerHTML = '<not-found></not-found>'
});

When Should You Use Web Components?

To be fair, Web Components aren't always the answer. Here's when they make the most sense:

Perfect for:

  • Design systems and component libraries
  • Public websites and content-focused applications
  • Long-term projects where stability matters
  • Teams wanting to reduce dependencies
  • Progressive enhancement scenarios

Consider frameworks when:

  • You need extensive server-side rendering
  • Your team is already deeply invested in a framework ecosystem
  • You're building a highly interactive, app-like experience with complex state
  • You need the extensive tooling and ecosystem of a mature framework

Conclusion

Frameworks have their place, but they've become the default answer when they should be a deliberate choice. Web Components offer a compelling alternative: native browser support, smaller bundles, standards-based stability, and zero dependencies.

The next time you start a project, ask yourself: "Do I really need a framework, or can Web Components do the job?" You might be surprised by how often the answer is the latter.

Start simple. Build with standards. Let complexity emerge only when necessary.

The platform is more powerful than we give it credit for.