12 min read

Vanilla Web - Part 1

Vanilla Web - Part 1

Let me take you on a journey with me to build SPAs with Vanilla Web. No frameworks, no runtime dependencies, just pure JavaScript and TypeScript. Let's figure out a way to build web components without a huge DX downgrade. We might re-invent the wheel, but we will do it in a way that is fun and educational.

Web Components Zero Dependencies JavaScript Web Development Vanilla Web

In this series, I will take you on a journey to build Single Page Applications (SPAs) without any frameworks or libraries. If you have been developing web applications for a while, you might have noticed that the landscape is dominated by frameworks like React, Vue, and Angular. Many developers have become framework-dependent, relying on these tools to handle everything. They are no longer building web applications; they are building Angular applications, React applications, or Vue applications. I am definitely not exempt from this as I extensively used Angular for pretty much everything I built in the past few years. However, I am curious to explore what it would be like to build web applications without any frameworks or libraries nowadays. We have got so many powerful features in the browser now. Native Observables, Custom Elements, Shadow DOM, Slots, Templates, Proxies, History API, just to name a few.

I am curious to see if we can build a web application that is as powerful and flexible as the ones we build with frameworks, but without the overhead of a framework.

Motivation

But first, why should we even bother about vanilla web? The developer experience (DX) of frameworks is top notch and they provide a lot of features. So why would we want to go back to the basics? Let me convince you that vanilla web is not as backwards thinking as it might sound.

I am working on one of the largest Angular applications in the world with millions of lines of code. This Angular repo is complex and evolved over the years and has lots of dependencies. The speed of developing new features is good because of the DX Angular has to offer, but there is a cost to this code. In the end, every line of code is a liability.

The more code you have, the more bugs you have. The more dependencies you have, the more third party risk you introduce. The more you are building on abstractions, the more you are losing control over your code.

It is my job to maintain this codebase, and let me tell you, it is not easy and extremely expensive. Frameworks have an extremely quick release cadence, and keeping up with breaking changes is a full-time job in itself. When you are building an SPA that is meant to last for decades, you have to be careful about the dependencies you introduce. Building software on a foundation that is changing twice a year is not sustainable - yet we do it all the time.

House Of Cards

Okay, I am making it sound like frameworks are the devil, but they are not. They have their place and they make our life easier in many ways. However, I wonder if the web platform has matured enough in the last decade to allow us to achieve the same level of productivity without relying on frameworks. Let’s find out together.

Guiding Principles

I am still not sure where this journey will take us, but I have a few guiding principles that I want to keep in mind while building this application:

I. Zero Dependencies

I truly want to build more resilient web applications that can be maintained for decades without the hassle of keeping up with framework and library updates. This means, we have to build everything from scratch. No frameworks, no libraries, no runtime dependencies. If we get this right, we can build a web application that is truly future-proof and can be maintained for a long time. Ideally, components that are written in this way, can be used in any framework or library without any issues and you should not have to upgrade them in the next decade at all.

This is a bold claim, but I believe it is possible. We have all the tools we need in the browser to build powerful web applications without any dependencies. We just have to use them. We might have to make some trade-offs, but I believe it is worth it.

What this does not mean is that we will not use dev dependencies. We are still sane people and we will use TypeScript for type safety and Vite for Hot Module Replacement (HMR). However, these dependencies will not touch the browser. They will only be used during development and build time. The final application will be pure JavaScript and TypeScript without any runtime dependencies. The dev dependencies are dependencies I am willing to live with, as the benefit they provide outweighs the maintenance cost, which is not the case for runtime dependencies.

II. Simplicity

Simplicity is key. We want to build a web application that is easy to understand and maintain. This means, we will not introduce complex abstractions or patterns that are hard to grasp. In the end, most of the SPAs today are written with frameworks such as React and Angular. We should make sure that our application is just as easy to work with, even if it is built without these frameworks. The code should feel familiar and have a similar structure to what you would expect from a framework-based application. This includes using familiar patterns like components, providers, and routing.

III. Web Components + Developer Experience

The idea behind Web Components is great. Isolated components built into the DOM, reusable across frameworks, and encapsulated styles. Sounds perfect, right? However, the developer experience of Web Components is not great in my opinion. The component authoring is verbose and reminds me of the early days of React class components, but even worse.

Frameworks like Lit have tried to improve the developer experience of Web Components, but they still rely on a lot of boilerplate code and abstractions. Additionally, Lit is too big of a dependency for my taste and is changing too frequently to be a good fit for a long-term project. The intention of Lit might be good, but I am not convinced that it is going to be as resilient as I want it to be.

Therefore, we will try to build our own abstractions for Web Components to get the developer experience we want. This leads us to the next principle about lean abstractions.

IV. Lean Abstractions

Abstractions are not great, but they are a necessary evil in favor of developer productivity. They are truly dangerous, when they are big and leak implementation details. Abstractions in frameworks are usually subject to change, and building on top of these leaky abstractions is a recipe for disaster. I believe, the bigger the abstraction, the more likely it is to leak implementation details and the more likely it is to change in the future.

However, we will need some abstractions to achieve a good developer experience. Therefore, we will try to build lean abstractions that are small and focused on a specific problem. We will not introduce complex abstractions even if that sometimes means we have to write more code, or the code is less performant. The the number one goal is to have a resilient codebase without the verbosity of pure web components.

V. Own the Framework

I am pretty sure, that we will end up building our own framework in the end. However, I want this “framework” to be super lean, such that it is possible to just copy and paste the framework code into your project. This means, anyone using this “framework” should be able to own and maintain it, which means it has to be small and easy to understand. No complex TypeScript infer type magic, no smart abstractions, just a bunch of utilities that make web component authoring better. The goal is to have a framework that is so simple, that you can understand it in a few hours and can maintain it for decades without any issues. The copy-pasteability should be possible, as these utilities are meant to be written once and never changed again. This is a bold claim, but I believe it is possible if we can keep the abstractions small and focused.

Exploring Web Component Authoring

Okay, here we go! I have started a new Vite project with the vanilla-ts preset. This gives us a good starting point with TypeScript and HMR. Check out the repo for the project structure:

Let’s start off with creating a simple counter with a pure vanilla web component.

// counter-component.ts
class CounterComponent extends HTMLElement {
  private count: number = 0;
  private shadow: ShadowRoot;
  private incrementButton!: HTMLButtonElement;
  private decrementButton!: HTMLButtonElement;
  private countDisplay!: HTMLElement;

  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
    this.attachEvents();
  }

  private attachEvents() {
    this.incrementButton.addEventListener('click', () => {
      this.count++;
      this.updateDisplay();
    });

    this.decrementButton.addEventListener('click', () => {
      this.count--;
      this.updateDisplay();
    });
  }

  private updateDisplay() {
    this.countDisplay.textContent = this.count.toString();
  }

  private render() {
    this.shadow.innerHTML = /* html */ `
      <div>
        <button id="decrement">-</button>
        <span id="count">${this.count}</span>
        <button id="increment">+</button>
      </div>
    `;

    this.incrementButton = this.shadow.querySelector('#increment')!;
    this.decrementButton = this.shadow.querySelector('#decrement')!;
    this.countDisplay = this.shadow.querySelector('#count')!;
  }
}

customElements.define('counter-component', CounterComponent);

Okay, this is a very simple counter, yet the code is already quite verbose. We have to write a lot of boilerplate code to get this working. The code itself is also very imperative and I wonder how well that would scale for more complex components. One thing I do appreciate about this though is that it is very performant. We are not updating the entire DOM tree, not replacing nodes, just making direct updates to DOM elements without black magic like virtual DOM diffing or change detection.

Me being me, I can’t help but think about how we could make this more declarative and less verbose. I have created two component authoring utilities, which has a clear performance trade-off, but makes the code much more declarative and easier to read.

1. Angular-like Component Authoring

This first utility is inspired by some of Angular’s life cycle hooks and has a MVVM-like structure.

export default createViewModel({
  tagName: "app-root",
  initialState,
  renderFn: (state) => /*html*/ `
    <button id="decrement">-</button>
    <span id="count">${state.count}</span>
    <button id="increment">+</button>
  `,
  onChanges: (changedProperty, value, state) => {
    console.log(
      `Property changed: ${changedProperty}, New value: ${value}, Current state:`,
      state
    );
  },
  onInit: (state) => {
    console.log("CounterViewModel initialized with state:", state);
  },
  setupEventListeners: (element, state) => {
    element.getElementById("increment")!.addEventListener("click", () => {
      state.count++;
    });

    element.getElementById("decrement")!.addEventListener("click", () => {
      state.count--;
    });
  }
});

2. React-like Component Authoring

The second utility is inspired by React’s functional components and hooks. It allows us to use a more functional approach to component authoring.

export function Counter(
  state: RxState<CounterState>, 
  shadowRoot: ShadowRoot
  ) {
  const increment = useEventListener("increment", shadowRoot, () => {
    console.log(state.state.count);
    state.state.count++;
  });

  const decrement = useEventListener("decrement", shadowRoot, () => {
    console.log(state.state.count);
    state.state.count--;
  });

  return () => /* html */ `
    <button id="decrement" onclick="${decrement()}">-</button>
    <span id="count">${state.state.count}</span>
    <button id="increment" onclick="${increment()}">+</button>
  `;
}

createComponent("app-root", createRxState(initialState), Counter);

Personally, the second utility is my favorite as it is so similar to React and I imagine coming from a React background, it would be easy to pick up. The first utility is also nice, but I feel like it is too verbose and not as declarative as the second one. The second one makes it also more obvious how the rendering works. This solution is not really performant as it just re-renders the entire component on every state change, but it is a good starting point to build more complex components.

How this works

You might be wondering how I can know when to re-render the component. The answer is simple: reactivity. We just need to know when the state changes and then we naively re-render the whole component.

Having had some background in .NET WPF development, which is heavily based on MVVM and implements a INotifyPropertyChanged interface for each property setter, I can see some parallels here. JavaScript does not have a built-in reactivity system like WPF, but we can leverage the Proxy API to make us aware whenever a state property is updated.

We can create a RxState interface that holds the state and a callback function that is called whenever a property changes.

export interface RxState<T extends object> {
  state: T;
  onChange: (callback: (changedProperty: keyof T, value: T[keyof T]) => void) => void;
}

export function createRxState<T extends object>(initialState: T): RxState<T> {
  let callback: ((changedProperty: keyof T, value: T[keyof T]) => void) | undefined;

  const notifyCallback = (changedProperty: keyof T, value: T[keyof T]) => {
    if (callback) {
      callback(changedProperty, value);
    }
  };
  
  const proxiedState = createProxy(initialState, notifyCallback);
  
  return {
    state: proxiedState,
    onChange: (cb: (changedProperty: keyof T, value: T[keyof T]) => void) => {
      callback = cb;
    }
  };
}

function createProxy<T extends object>(
  target: T, 
  notifyCallbacks: (changedProperty: keyof T, value: T[keyof T]) => void
): T {
  return new Proxy(target, {
    set(obj, prop, value) {
      // Check if the value actually changed
      if (obj[prop as keyof T] !== value) {
        obj[prop as keyof T] = value;
        notifyCallbacks(prop as keyof T, value);
      }
      return true;
    },
    get(obj, prop) {
      const value = obj[prop as keyof T];
      return value;
    }
  });
}

You might have also noticed our createComponent function. This is a utility function that creates a custom element and sets up the component with the provided state and render function and automatically triggers re-renders when the state changes.

export type Component<T extends object> = (
  state: RxState<T>,
  shadowRoot: ShadowRoot
) => () => string;

export function createComponent<T extends object>(
  tagName: string,
  state: RxState<T>,
  component: Component<T>
) {
  class CustomElement extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
      const c = component(state, this.shadowRoot!);
      this.#render(c);
      state.onChange(() => {
        this.#render(c);
      });
    }

    #render(component: () => string) {
      this.shadowRoot!.innerHTML = component();
    }
  }

  customElements.define(tagName, CustomElement);
  return CustomElement;
}

The Component type is the functional component that we are writing. It takes the RxState and the ShadowRoot as parameters and returns the render function that simply returns a string of HTML. The only missing piece right now is the useEventListener function, which is a utility to create event listeners that can update the state on events.

export type EventDispatcher = () => string;

export function useEventListener(
  eventName: string,
  shadowRoot: ShadowRoot,
  callback: () => void
): EventDispatcher {
  shadowRoot.addEventListener(eventName, callback);

  return () =>
    /* javascript */ `this.dispatchEvent(new CustomEvent('${eventName}', { bubbles: true }))`;
}

For this, we are dispatching a custom event that we are bubbling up to the shadow root. This way, we can always listen to the event on the shadow root and update the state accordingly. The useEventListener function returns a string that can be used in the HTML to trigger the event. The utility function returns callback that returns the custom event dispatching as a string. Why you might ask? Because this EventDispatcher is used in the render function for rendering the HTML, which is just a string literal template.

Conclusion

We have already come a long way in this first part of the series. We made web component authoring way more declarative and easier to read. People coming from a React background will feel right at home with the second utility. It is very similar to React, but no virtual DOM, no JSX, just pure web components that are meant to last for decades.

I am excited to see where this journey will take us. In the next part, we will explore more complex components…

Todos:

  • Inputs/Outputs
  • Untracked utilities to not trigger re-renders
  • useEffect or some onInit hook

Please let me know what you think about this approach. I am curious to hear your thoughts and ideas on how we can improve this further.

Stefan Haas

Stefan Haas

Senior Software Engineer at Microsoft working on Power BI. Passionate about developer experience, monorepos, and scalable frontend architectures.

Comments

Join the discussion and share your thoughts!