Shortround's Space

Dependency Injection with Web Components

Web components are probably our best bet to end our dependency on overly large Javascript frameworks, but they have a few weaknesses that I've run into. Constructors, on their own, are a bit useless. When working on a Typescript web component, I wanted to initialize some private readonly fields based on its child elements or HTML attributes only to find that they are not guaranteed to exist when the constructor runs: they need to be declared as mutable (i.e: no readonly) and optional (i.e: ?) since non-optional members which are undefined in the constructor generate errors in typescript. This kind of ruins the class-based aspect of web component integration with typescript.

However, constructors are good for one thing: dependency injection. I've seen a few blog posts from people who have developed a length sort of dependency injection framework with factories and scoped/singleton dependency injection which mirrors frameworks like Angular, but I've been using a more Javascript-idiomatic technique that works very well for me and is quite lightweight. Since classes are sort-of-types, sort-of-objects in Javascript, they can be defined inline when registering a web component. Consider the following web component in typescript:

class BlogPostComponent extends HTMLElement {
    private readonly _shadow: ShadowRoot =
        this.attachShadow({ mode: 'open' });

    constructor(
        private readonly _blogPostService: IBlogPostService
    ) {
        super();
    }

    public async connectedCallback(): Promise<void> {
        const id = this.getAttribute('post-id');
        const post = await this._blogPostService.getPost(id!);
        this._shadow.innerHTML = `
            <h1>${post.title}</h1>
            <p>${post.body}</p>
        `
    }
}

Simple enough, but when you go to register it:

const blogPostService: IBlogPostService = new BlogPostService();
customElements.define('blog-post', BlogPostComponent)

How do you get the blogPostService into the constructor? With an inline child class!

const blogPostService: IBlogPostService = new BlogPostService();
customElements.define(
    'blog-post',
    class RealizedBlogPostComponent extends BlogPostComponent {
        constructor() {
            super(blogPostService);
        }
    }
);

And that's it! In my webpack projects I define an index.prod.ts and an index.mock.ts for production vs demo purposes, and inject Mock services from this entrypoint when registering my web components. You then use the web component like this:

<blog-post post-id="123"></blog-post>

You can debate whether or not this is a pattern or antipattern (should web components request their own data or should their data be injected into them?) but this is a very lightweight way to get implemented interfaces into a component.

Here is a demo in action

Thoughts? Leave a comment

Comments
  1. Danny Engelman — Jan 11, 2025:

    super sets AND returns the 'this' scope, attachShadow creates AND returns 'this.shadowRoot'; no need to create your own _shadow So you can chain then: super().attachShadow()