Custom Elements with Dependency Injection

Custom elements, a key feature of the Web Components standard, appear to be incompatible with the constructor injection pattern. But there's a way to make it work.


Building the UI of a web app with Web Components sounds attractive. You get encapsulated, reusable UI components natively (or with a polyfill) in the browser without the bloat of a JavaScript framework.

If your app is complex enough such that your custom elements rely on a non-trivial number of dependencies, such as classes for state management, business logic, or configuration, you'll probably want to implement some sort of dependency injection pattern. Otherwise, your custom elements will likely be difficult to test and tightly coupled to particular implementations of those dependencies.

A common dependency injection pattern is constructor injection. The dependencies of a class are declared as parameters in its constructor. Instances of these dependencies are then "injected" by whatever code is responsible for creating class instances. Typically, this is a dependency injection container.

class GilbertGrapeService {
// Dependencies are "injected" as arguments into a class's constructor.
constructor(private readonly actor: JohnnyDeppendency) { }

start(): void {
const acting = this.actor.act()
// ...
}
}

Custom elements are classes. They have constructors. So why not use constructor injection with custom elements?

The problem

A requirement for implementing this pattern is that we have access to class instance construction. That is, we (or the DI container we configure) need to be able to call the class's constructor so that we can pass in its dependencies.

This, however, poses a problem for using constructor injection with custom elements. When we register a custom element, we pass a constructor and a name to the CustomElementsRegistry:

window.customElements.define('excellent-element', ExcellentElement)

But, if we want to use excellent-element in HTML, we don't have access to the constructor call; the constructor is called by the browser at some point after it parses <excellent-element></excellent-element>. There's no way to intercept this call and pass in the correct dependencies.

You can create a custom element instance using its constructor and attach it to the DOM like this (see here):

const superlativeService = new SuperlativeService()
const el = new ExcellentElement(superlativeService)
document.body.appendChild(el)

However, this eliminates one of the main benefits of custom elements: their ability to be used in HTML just like native elements.

You could run your HTML through a build step that compiles custom element HTML tags to JavaScript like the above. But it's easy to see how this can get complicated quickly. At that point, it would probably be better to reach for a JavaScript framework instead.

A solution

We need to pass a constructor to customElements.define. But, there's no reason that it has to be the original custom element constructor. All we need is a constructor that returns an object instance which:

  1. is an instance of the original custom element class
  2. has access to the dependencies declared as parameters in the original custom element class constructor
  3. has functionally equivalent behavior to the original custom element class

We can achieve 1 and 3 by declaring a "dummy" subclass of the original custom element. Also, declaring the dummy class's constructor creates a closure. If we have in-scope references to the dependencies we need, we can pass these into the super() call of the dummy subclass, satisfying 2. Something like the following:

// The original custom element class.
class ExcellentElement extends HTMLElement {

constructor(private readonly service: SuperlativeService) {
super()
}

connectedCallback() {
const p = document.createElement('p')
p.innerText = this.service.getSuperlativeText()
this.appendChild(p)
}
}

// A reference to a required dependency instance.
const service = new SuperlativeService()

// The "dummy" subclass.
class ExcellentDummy extends ExcellentElement {
constructor() {
// Pass ExcellentElement's dependency to its constructor via super().
super(service)
}
}

window.onload = _ => {
customElements.define('excellent-element', ExcellentDummy)

// customElements.get() returns the constructor of the defined Custom Element
const excellentEl = customElements.get('excellent-element')
const excellentInstance = new excellentEl()
console.log(excellentInstance instanceof ExcellentElement) // true
}

This works--you can use <excellent-element></excellent-element> with this technique. However it has a major drawback.

We cannot create dynamic class declarations at runtime. This means that each custom element class declaration must be accompanied by another class declaration for its dummy subclass. That's a nontrivial amount of extra code.

Even worse, the dummy subclass declaration must reference instances of the custom element's dependencies. Since we must write the class declaration explicitly, we have to explicitly resolve each dependency (e.g., const service = new SuperlativeService()) and put it into the dummy subclass declaration. You can see how this can quickly get out of hand with even a few custom elements with a few dependencies each.

A better approach is to use a class expression for the dummy subclass. It accomplishes the goal of creating a constructor that will return an instance of the custom element with access to its dependencies. And it can do so at runtime. For example, we can do something like the following:

interface CustomElementConstructor {
new (...dependencies: any[]): HTMLElement
}

function defineElement(
name: string,
elementConstructor: CustomElementConstructor,
dependencies: any[]): void {

customElements.define(
name,
// Pass an anonymous class expression
// instead of the custom element constructor.
class extends elementConstructor {
constructor() {
super(...dependencies)
}
}
)
}

const service = new SuperlativeService()

window.onload = _ => {
defineElement('excellent-element', ExcellentElement, [service])

const excellentEl = customElements.get('excellent-element')
const excellentInstance = new excellentEl()
console.log(excellentInstance instanceof ExcellentElement) // true
}

Now, we don't need an extra class declaration for each custom element. We can pass each custom element's constructor to the defineElement function and this will create a dummy subclass for it and register it as a custom element.

But what about the dependencies? It looks like we're still resolving them explicitly.

It doesn't have to be this way. It is possible to register custom elements using this technique and resolve their dependencies programmatically. In principle, we only need two functions: i) a function that retrieves injection tokens for the dependencies defined in the custom element's constructor and ii) a function that exchanges an injection token for the actual dependency instance. So, assuming we have these two functions, defineElement would be amended to look like this:

function defineElement(name: string, elementConstructor: CustomElementConstructor): void {
const tokens: InjectionToken[] = getTokens(elementConstructor)
const dependencies: any[] = tokens.map(token => exchangeToken(token))
customElements.define(
name,
class extends elementConstructor {
constructor() {
super(...dependencies)
}
}
)
}

One possible method for implementing these two functions is to use TypeScript Decorators and the Metadata Reflection API. Your custom element class decorator function uses reflection to assign metadata to these classes, which contain injection tokens for their constructor parameters. Then in your defineElement function, you retrieve these metadata for your custom element class and pass the tokens to your dependency injection container.

That explanation was a bit abstract. If you would like to see an example of how to do this, I've written a library called cewdi. It's a dependency injection container that also registers custom elements using the class expression technique described above. I'm sure there are other, better ways of accomplishing this, but, I've been using it on this site without any major issues.