Using Angular Elements in Non-Angular Projects

Anton Ioffe - November 24th 2023 - 11 minutes read

In the ever-evolving landscape of web development, the quest for modular and maintainable code often leads us to explore new frontiers in component architecture. Within these pages, we unravel the sophistication of Angular Elements and how these potent parcels of functionality can transcend the Angular ecosystem to invigorate non-Angular projects with reusable, standalone web components. Join us as we journey through the intricacies of sculpting agile web elements—navigating the technical feats of integrating them into a polyglot landscape, optimizing performance, and mastering inter-component discourse, all while keeping a keen eye on the advanced patterns that harmonize state management within your diverse tech stack. Whether you're looking to bolster your testing regime or hone your deployment strategy, this treatise is poised to arm you with the insights essential for architecting an inter-operable and resilient web presence.

Harnessing Angular Elements in a Polyglot Environment

Angular Elements encapsulate Angular's core features into custom web components, making it possible to integrate them into various web environments without requiring Angular as a dependency. They are standard Angular components packaged to comply with the Custom Elements API. This accommodating design allows for seamless integration within diverse platforms such as React and Vue, providing a versatile solution for embedding Angular functionality into non-Angular projects.

These custom elements are an asset in polyglot environments where multiple technologies coexist. They can be seamlessly introduced as native HTML elements, allowing for high levels of code reusability and avoiding potential conflicts with the existing tech stack. Moreover, these elements take Angular's strengths—like dependency injection and content projection—and weave them into components that can comfortably settle into non-Angular frameworks. Although limitations currently exist with aspects like dynamic content projection, the progressive enhancement of Angular Elements promises an evolution of these capabilities.

Adding Angular functionality, such as data-binding or lifecycle hooks to an existing project, becomes more approachable with Angular Elements. Consider the following example of a custom 'date-picker' element:

// Import core Angular and Angular Elements functionality
import { Component, Input, NgModule, Injector } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { BrowserModule } from '@angular/platform-browser';

// Define the Angular component
@Component({
  selector: 'app-date-picker',
  template: `<input type="date" [value]="selectedDate" (input)="onDateChange($event)" />`
})
export class DatePickerComponent {
    @Input() selectedDate: string;

    onDateChange(event: Event): void {
        const date: string = (event.target as HTMLInputElement).value;
        // Additional logic for handling date change
    }
}

// Define the module and declare the component
@NgModule({
  declarations: [DatePickerComponent],
  imports: [BrowserModule],
  entryComponents: [DatePickerComponent]
})
export class AppModule {
    constructor(private injector: Injector) {
        // Create a custom element from the Angular component
        const DateElement = createCustomElement(DatePickerComponent, { injector });
        // Define the custom element in the browser
        customElements.define('app-date-picker', DateElement);
    }
    ngDoBootstrap() {}
}

The code above packages the DatePickerComponent as a custom element. This element can now be used in any HTML file as <app-date-picker></app-date-picker>, regardless of the underlying web technology being employed on the page.

Angular Elements also streamline collaboration across different technology stacks. They permit Angular developers to construct components that fit organically into a broader application, managed by teams employing a variety of frameworks. By reducing the need for team members to learn Angular in depth, Angular Elements encourage a unified development practice, focusing on functionality rather than framework specifics.

In sum, Angular Elements symbolize Angular's capability to extend beyond its traditional boundaries, allowing its components to operate effectively within different web ecosystems. Through the lens of the Custom Elements standard, Angular Elements foster interoperability and smooth integration into the landscape of modern web development.

Design Considerations and Performance Strategies

When incorporating Angular Elements into non-Angular projects, one primary concern is managing the bundle size. The size of the final deliverable is critical as it directly affects application load time and performance. Although Angular's Ivy compiler has improved tree-shaking and dead code elimination, developers must remain vigilant. Ensuring that only the necessary features of Angular are included requires thorough auditing of included modules and components. Recognizing that each Angular Element pulls in part of the Angular runtime, developers should strive to minimize the number of Angular Elements used and rationalize their necessity within the broader application architecture.

Another key design consideration is the strategy for change detection. Angular's default change detection mechanism can lead to performance issues when dealing with a large number of components or complex data hierarchies. To circumvent this, developers should consider onPush change detection and the manual triggering of change detection cycles for Angular Elements. This approach minimizes the performance overhead by reducing unnecessary checks and updates, making it particularly beneficial in a hybrid application setting where Angular is not managing all DOM elements.

Lifecycle management in Angular Elements is no trivial matter, given the varied interactions that might happen within a non-Angular environment. To ensure seamless integration and efficient memory use, developers need to handle the creation and destruction of Elements properly. This includes leveraging lifecycle hooks such as ngOnInit and ngOnDestroy to initialize and clean up resources, as well as being mindful of any subscriptions or event listeners that may cause memory leaks if not removed when an element is destroyed.

Leveraging lazy loading for Angular Elements is a robust strategy for performance optimization. Code-splitting allows for the partitioning of an app's code base into manageable chunks that are only loaded upon request. However, the challenge lies in preventing race conditions and ensuring that the required code is accessible before the custom element is needed. To effectively implement lazy loading, developers can use dynamic imports combined with Angular's module system to define clear entry points and loading sequences, thus ensuring that elements are loaded in a timely and predictable manner without adding undue complexity.

Avoiding common pitfalls is essential to maintaining a high-performance hybrid application. Overlooking zone.js settings can lead to performance degradations, especially when there are multiple zones with different change detection strategies. It is also critical to minimize DOM manipulations and be aware of potential conflicts between Angular's rendering cycles and those of the host framework. In addition, directly manipulating the DOM outside of Angular's context should be avoided as it can lead to unpredictable behavior and issues with data-binding synchronicity. Adhering to these guidelines not only prevents performance bottlenecks but also fosters a modular, readable, and maintainable codebase within diverse technological ecosystems.

Inter-Component Communication and Data Binding Techniques

Data transfer between a parent context and Angular Elements is elegantly handled using a combination of attribute bindings for inputs and custom events for outputs. For input data, Angular components traditionally utilize property binding denoted by square brackets. However, when Angular components are transformed into custom elements, these inputs are exposed as attributes on the HTML element, typically expressed in dash-separated-lowercase form. For instance, to pass a userId into an Angular Element, you would add it as an attribute:

<my-profile-card user-id='42'></my-profile-card>

Within the Angular Element, a corresponding @Input() decorator binds to the userId, making it accessible within the component. Angular's change detection will trigger when these attributes change, but integration into non-Angular projects might require manual updates, as zone.js is not present in those environments.

Outputs, on the other hand, are events dispatched from within the Angular Element. An EventEmitter is used, decorated with @Output(). When a specific action occurs, like a button click within the custom element, it emits an event.

<button (click)='onButtonClick()'>Click me</button>

In your component:

@Output() buttonClicked = new EventEmitter<string>();

onButtonClick() {
    this.buttonClicked.emit('clicked');
}

To listen to this event outside the Angular context, you would attach an event listener to the custom element:

document.querySelector('my-profile-card')
    .addEventListener('buttonClicked', (event) => {
        // Event handling logic
    });

Regarding two-way data binding, this is a concept rooted deeply in Angular but can be emulated in a non-Angular project using a combination of inputs and outputs. When using Angular Elements, two-way data binding can be manually created by responding to output events and updating the corresponding input attributes accordingly.

<my-toggle id='myToggle' checked='false'></my-toggle>
<script>
  const myToggle = document.getElementById('myToggle');
  myToggle.addEventListener('change', (event) => {
    myToggle.setAttribute('checked', event.detail);
  });
</script>

In the Angular component:

@Input() checked: boolean;
@Output() change = new EventEmitter<boolean>();

// This method is connected to the toggle UI element in the component's template,
// such as a checkbox, which when interacted with, will call this method.
toggle() {
    this.checked = !this.checked;
    this.change.emit(this.checked);
}

Implementing communication in this manner requires a keen understanding of event-driven programming and explicit handling of data flow, to maintain synchronization across disparate elements of the UI.

Lastly, when incorporating Angular Elements into a non-Angular project, the absence of Angular's zone.js could be noteworthy. While zone.js excels in automatic change detection, in a mixed environment, developers must be cognizant of its absence and be prepared to handle any updates imperatively. For example, to update UI in response to a data change, a developer might need to call a render() function manually:

function render() {
    // Manually triggering UI updates in the absence of zone.js
    // This function would contain logic to update the DOM based on new data
}

This absence can be an advantage to those seeking finer control over performance, as it eliminates the additional overhead of Angular's zone. However, the task of ensuring views are updated falls on the developer's shoulders, necessitating a clear strategy for triggering UI updates when underlying data changes.

Advanced Integration Patterns and State Management

Incorporating Angular Elements within a SPA architecture amplifies existing possibilities—onboard an Angular component as a micro-frontend and immediately take advantage of its encapsulation. The complexity surfaces when you attempt to manage state across these components, especially when they are meant to interact within a larger non-Angular ecosystem. With state management being pivotal, exploring advanced integration patterns requires considering third-party libraries like Redux or the reactive streams of RxJS. Redux offers a single source of truth for state but using it with Angular Elements mandates additional architecture to sync state outside Angular's zone, perhaps by intercepting custom events emitted by the elements to trigger Redux actions.

Here is an example that succinctly illustrates Redux integration:

// Create a Redux store and define the actions
const store = Redux.createStore(reducer);
document.addEventListener('customActionEvent', (event) => {
    store.dispatch({ type: event.detail.type, payload: event.detail.payload });
});

Opting for RxJS, you are leveraging Angular's preferred pattern of observables. It's inherently more adept at handling async operations and streams of data, yet it introduces the challenge of coordinating change detection in a non-Angular host. This might look like the following in practice:

// Define an RxJS subject to act as a data store
const stateSubject = new Rx.BehaviorSubject(initialState);

// Subscribe to the state changes and handle them manually
stateSubject.subscribe(newState => {
  // Manually update your non-Angular parts of the app as needed
});

State consistency is crucial to avoid a fragmented user experience when integrating Angular Elements. To foster consistency, set up communication channels using custom events and attributes. Beyond this, a shared worker may serve as a hub for state between Angular Elements and the parent environment.

The key lies in favoring simple JavaScript objects for state and leveraging standard DOM mechanisms for interactivity:

// Use plain JavaScript objects and DOM events to interact with Angular Elements
element.addEventListener('change', (event) => {
    // Handle Angular Element output and sync with the host application state
});

When scaling an application with Angular Elements, it’s crucial to analyze whether Redux's single store approach or RxJS's stream management is more adaptable. Consider an example where an app's interactivity intensifies:

// A real-world scenario requiring frequent state updates
store.subscribe(() => {
  // Sync state with Angular Element inputs and respond to outputs
});

// As opposed to managing multiple streams in RxJS
const clickStream = new Rx.Subject();
const dataStream = clickStream.map((event) => {
    // Process click events and return updated state
});

Reflect on the balance between embracing Angular's features and maintaining a project's longevity. Angular’s rich feature set shouldn't overshadow the project's long-term goals or maintainability. The choice of state management, be it Redux or RxJS, should hinge on performance, scalability, and alignment with the development team's proficiency.

Testing and Deployment Considerations for Angular Elements

When testing Angular Elements, you must tailor your strategies to account for the unique challenges they present. Unit tests are essential and can be implemented using Angular's testing utilities to verify the internal logic of components. However, when it comes to integration and end-to-end tests, Angular Elements should be tested in the context where they will be used — as part of a non-Angular application. Tools such as Cypress or Selenium can facilitate this, allowing you to validate interactive workflows and ensuring event handling is consistent across different environments. By testing within the target environment, you’re more likely to uncover issues that would not be apparent when testing solely within the Angular ecosystem.

Deployment of Angular Elements requires a systematic approach. The build pipelines must be configured to output the custom elements as part of the build artifacts. This typically involves setting up Angular CLI or Webpack configurations to compile the Angular code, execute the necessary polyfills, and bundle the elements. Emphasizing modular builds will enhance reusability and help in better managing the deployment processes. Given that Angular Elements can represent standalone features or entire micro-frontends, having a CI/CD pipeline that supports independent deployment is paramount — enabling a more dynamic and flexible delivery strategy.

Deploying to a micro-frontend architecture means that your build system should also be able to deploy elements as individual assets that can be loaded on demand. With proper versioning and cache-busting strategies in place, these elements can be retrieved and updated by client applications without needing to redeploy the entire application. Ensure that your deployment pipeline respects semantic versioning and supports rollback, facilitating gradual rollouts and easy reversions if necessary.

When integrating Angular Elements with non-Angular projects, observe how the bootstrapping and teardown processes of these elements align with the lifecycle of the host application. Ensure that custom elements are initialized and removed correctly, in sync with the external application’s navigation or component lifecycle. This often goes hand-in-hand with the provision of a container element or a lifecycle hook in the host application that can effectively mount and dismantle the Angular Element. Overlook this, and you risk memory leaks or inconsistent application states.

Finally, it’s crucial to assess how Angular Elements will operate in tandem with the host application's update cycles. Since Angular Elements encapsulate their own logic and state, and non-Angular environments may not have the same change detection mechanisms, developers should ensure that property changes are propagated to elements correctly, and events emitted by elements are captured by the host application smoothly. This aspect of integration requires thorough testing to prevent UI and data inconsistencies. To aid in this, consider introducing a wrapper or a mediator that can orchestrate state updates and event handling between Angular Elements and the surrounding application.

Summary

The article explores the use of Angular Elements in non-Angular projects, discussing their capabilities and benefits, as well as design considerations, performance strategies, inter-component communication, state management, and testing/deployment considerations. Key takeaways include the versatility of Angular Elements in polyglot environments, the need for careful management of bundle size and change detection, and the importance of choosing the right state management approach. The challenging technical task involves integrating Angular Elements into a non-Angular project and implementing a communication channel using custom events and attributes to sync state between the elements and the host application.

Don't Get Left Behind:
The Top 5 Career-Ending Mistakes Software Developers Make
FREE Cheat Sheet for Software Developers