Building Custom Elements with Angular's API

Angular Elements provides a mechanism for packaging Angular components so they can function independently of the Angular framework. This capability allows development teams working with different technologies, such as React, to integrate Angular-built features into their own applications.

The implementation relies on Web Components standards, which enable the creation of reusable custom HTML tags. These tags encapsulate their internal logic, making them self-contained and decoupled from the surrounding application code.

Setting Up the Initial Project

Start by generating a new project with the Angular CLI.

ng new angular-elements-example

Next, add the Angular Elements package to the project.

ng add @angular/elements

Proceed to create a sample component.

ng g c test-component

Once the project scaffolding is complete, it's time to register the element.

The createCustomElement() function is responsible for transforming a component into a web element. This function must be invoked within the main application module.

export class AppModule {
  constructor(injector: Injector) { 
    const el = createCustomElement(TestComponent, { injector: injector });
    customElements.define('test-component', el);
  }
}

In the provided example, the TestComponent is referenced, and its corresponding web element is defined with the tag <test-component>.

The element becomes dynamic, enabling its usage within ordinary JavaScript code.

ngOnInit() {
  document.querySelector('#container').innerHTML = '<test-component></test-component>'
}

It's plausible that the code won't execute correctly initially, resulting in a console error.

Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function.

This issue indicates a need for browser polyfills. Installing the @webcomponents/webcomponentsjs package will provide the necessary support.

npm i @webcomponents/webcomponentsjs

This package should then be imported into the polyfills.ts file.

import '@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js'

With this step completed, the element should function as intended.

Managing Data with Input() and Output()

Consider a component designed to render a list of Users. An Input() property receives the array of users, while an Output() event is emitted to handle the selection of a specific user.

It's recommended to use lowercase naming conventions for Input() and Output() properties. For instance, using 'userlist' is safer than 'userList'. While this distinction may not matter in a standard HTML page, it can cause issues when the element is integrated into a React application.

Treating Input() as a string is also crucial. When data like objects or arrays is passed from external applications, its handling can be unexpected. These applications often use the toString() method instead of performing proper serialization. Therefore, a setter for the userslist property is implemented. This setter converts incoming JSON data to the internal list property, which is then rendered by the view.

import { Component, OnInit, EventEmitter, Input, Output } from '@angular/core';


export interface User {
  name: string;
}

@Component({
  selector: 'app-users-list',
  templateUrl: './users-list.component.html',
  styleUrls: ['./users-list.component.sass']
})
export class UsersListComponent {
  list: User[];
  @Input()
  set userslist(userslist: User[]|string) {
    if (typeof userslist === 'string') {
      try {
        this.list = JSON.parse(userslist);
      } catch {}
    } else if (Array.isArray(userslist)) {
      this.list = userslist;
    }
  }
  @Output() userselect = new EventEmitter();
}

<p *ngFor="let user of list" (click)="userselect.emit(user)">
  {{ user.name }}
</p>

The Input() property can be set from JavaScript by referencing the HTML element and assigning a value to the userslist attribute.

const el = document.createElement('users-list') as any;
el.userslist = [{name: 'John'}, {name: 'IronMan'}];
document.querySelector('#users-container').appendChild(el);

To properly capture the Output() events, an EventListener must be attached to the element.

el.addEventListener('userselect', e => console.log(e.detail));

Preparing Elements for External Use

The build for an Angular Elements project will not include a root AppComponent or utilize a standard NgModule bootstrap. These conventional startup components are not required, as the application does not need to boot its own component tree.

@NgModule({
  imports:      [ BrowserModule, FormsModule ],
  declarations: [ AppComponent, UsersListComponent ],
})
export class AppModule {
  constructor(injector: Injector) {
    const el = createCustomElement(UsersListComponent, { injector: injector });
    customElements.define('users-list', el);
  }

  ngDoBootstrap() {}
}

In the configuration above, the ngDoBootstrap() method is included to replace the standard bootstrap. This function explicitly instructs the application to start without any designated root component.

Running a production build without cache mode.

ng build --prod --output-hashing=none

The build command creates several files in the dist/angular-elements-example directory, specifically main.js, polyfills.js, and runtime.js. For easier deployment, it's practical to combine these into a single file. A demo folder can be created, and a command can merge the three files into one.

cat dist/angular-elements-example/runtime.js dist/angular-elements-example/polyfills.js dist/angular-elements-example/main.js > demo/angular-elements-example.js

This process yields a standalone file, angular-elements-example.js, which is ready to be included in any web page.

The example project is available on GitHub.

Integration with a Standard HTML Page

Incorporating the element into a basic HTML page is straightforward. The compiled script, angular-elements-example.js, simply needs to be referenced within the index.html file.

There are two distinct ways to add it: either statically in the markup or dynamically at runtime. Both methods support interaction with Input() and Output().

For the static approach, a JSON array is passed directly to the element.

<html>
  <body>
  <script src="./angular-elements-example.js"></script>
  <h1>Static</h1>
  <users-list userslist='[{"name":"John"},{"name":"IronMan"}]'></users-list>
  <script>
    document.querySelector('users-list').addEventListener('userselect', e => alert(`output static ${JSON.stringify(e.detail)}`));
  </script>
  <h1>Dynamic</h1>
  <script>
    const el = document.createElement('users-list');
    el.userslist = [{name: 'John 2'}, {name: 'IronMan 2'}];
    el.addEventListener('userselect', e => alert(`output dynamic ${JSON.stringify(e.detail)}`));
    document.querySelector('body').appendChild(el);
  </script>

</body>
</html>

A live demo of this HTML integration is available on StackBlitz.

Integration with a React Application

Similar to the HTML page, the element's JavaScript file must be added to the React project. This is done by placing the file in the src directory and importing it within the root index.js file.

Data passed to the Input() property should be converted to JSON. As previously noted, React might invoke the toString() method, which is not suitable for complex data structures.

Handling Output() involves using addEventListener. Within a React component, it's vital to clean up by removing the EventListener in the appropriate lifecycle hook when the component unmounts.

import React, { Component } from 'react';
import './App.css';

class App extends Component {
  users = JSON.stringify([{name: 'John Wick'}, {name: 'IronMan'}]);

  componentDidMount() {
    this.component.addEventListener('userselect', this.onUserSelect);
  }

  componentWillUnmount() {
    this.component.removeEventListener('userselect', this.onUserSelect);
  }

  onUserSelect(event) {
    console.log(event.detail);
  }

  handleRef = component => {
    this.component = component;
  };

  render() {
    return (
      <users-list userslist={this.users} ref={this.handleRef}></users-list>
    );
  }
}

export default App;

You can view the React integration example on StackBlitz.

Integration with a Vue Application

The principles for integrating with Vue are consistent with those for React. The user array passed to the component should be formatted as JSON.

When handling the Output() event, the function will receive a CustomEvent object. The relevant data can be accessed through the event's detail property.

<template>
  <users-list :userslist="users" @userselect="userselect"></users-list>;
</template>

<script>
  export default {
    name: 'App',
    data() {
      return {
        users: JSON.stringify([{ name: "John Wick" }, { name: "IronMan" }])
      }
    },
    methods: {
      userselect(event) {
        alert(JSON.stringify(event.detail))
      }
    }
  }
</script>

An example of the Vue integration is available on StackBlitz.

Concluding Thoughts

Angular Elements is a powerful solution for reusing Angular components across a wide range of applications. They are particularly suitable for building microservice architectures or shared UI component libraries. This is a technology definitely worth exploring further as its potential is substantial.