Why Web Component Inputs Vanish on Angular Route Changes
Picture this: one team in your organization maintains a library you need to drop into your Angular application. That library is built with a different framework—Svelte or React, for instance—and it exposes a Web Component for easy integration.
Say it’s a card payment widget called payment-widget. It takes inputs like country and entity and validates them during its own construction. If those values are missing or invalid, the widget throws an error. Your Angular code may look like this:
@Component({
selector: 'app-payment-wrapper',
template: `
<payment-widget [attr.country]="country()" [attr.entity]="entity()" />
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PaymentWidgetComponent {
readonly country = input.required("")
readonly entity = input.required("")
// ... other code
}
On the initial load, everything works. The attributes are set, the widget initializes correctly, and the payment form renders as expected. Then you navigate to another route—the wrapper component is destroyed—and later return. This time you get an error: Bad or unsupported value 'undefined' of input parameter 'entityUid'. The Angular component still holds the correct value, and the template binding still exists. The Web Component, however, behaves as though the input never arrived.
This article comes from a real debugging session. Our team hit this exact issue, and it took a while to understand why the widget worked only once. Our initial assumption was that binding with [attr.*] would push updates to the element whenever the value changed. That's true, but it turns out the timing doesn't align the way you'd expect with Web Components.
When you place a custom element directly into an Angular template, the first visit to the page looks fine. Angular creates the wrapper component and renders the HTML element. The required inputs like country or entity are usually available by then, or they resolve quickly. Angular first creates the DOM node, then applies the [attr.*] bindings right after.
The issue appears only after a navigation round-trip. Angular destroys the wrapper when you leave the route, and constructs a fresh one when you come back. Here's the catch: the browser runs a custom element's constructor as soon as it's attached to the DOM. This means the widget's constructor fires before Angular has a chance to set any attributes. At that point, entity or country are still undefined, even though Angular has valid values ready in memory. And the widget can't recover—the initialization has already failed, and setting attributes later doesn't help.
This explains why the first render succeeds and the second one fails. It's not caching or a state leak. It's the lifecycle mismatch: Angular expects to create an element first and configure it afterward, while a Web Component expects to receive its full configuration the moment it attaches to the DOM.
Approaches That Don't Work
Before showing the working fix, let me walk through what we tried and why it didn't help. One idea was to conditionally render the widget only when the inputs were available:
@Component({
selector: 'app-payment-wrapper',
template: `
@if(country() && entity())
<payment-widget
[attr.country]="country()"
[attr.entity]="entity()" />
}
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PaymentWidgetComponent {
readonly country = input.required('');
readonly entity = input.required('');
}
A condition only controls when Angular adds the element to the DOM. As soon as the element is inserted, the browser immediately initializes the Web Component. Angular still applies the attribute bindings after the element is connected, so the widget can still initialize with undefined values, especially when returning to a route after navigating away.
We also tried setTimeout(), afterRendererEffect, and eventually hardcoded the country and entity values in multiple places, then used a switch statement in the template to pick which payment-widget to render. That worked, but it was hardly maintainable or scalable. It did point us toward the right approach.
The Reliable Fix
The only consistent solution is to stop letting Angular create the Web Component from a template. Instead, you construct the element yourself, set every required attribute, and only then insert it into the DOM. Here's an example using the Renderer service:
@Component({
selector: 'app-payment-wrapper',
template: `<div #elRef></div>`,
})
export class PaymentWidgetComponent {
private readonly renderer = inject(Renderer2);
private readonly destroyRef = inject(DestroyRef);
readonly elRef = viewChild('elRef', { read: ElementRef<HTMLElement> });
readonly country = input.required('');
readonly entity = input.required('');
// reference for the HTML element for cleanup
private paymentWidget?: HTMLElement;
constructor(){
afterRendererEffect(() => {
const paymentWidget = this.renderer.createElement('payment-widget');
const elementRef = this.elRef().nativeElement;
this.paymentWidget = paymentWidget;
// set input attributes
this.renderer.setAttribute(el, 'country', this.country());
this.renderer.setAttribute(el, 'entity', this.entity());
// attach to the DOM
this.renderer.appendChild(elementRef, paymentWidget);
});
// cleanup - release memory
this.destroyRef.onDestroy(() => {
if (this.paymentWidget) {
this.renderer.removeChild(host, this.paymentWidget);
this.paymentWidget = undefined;
}
});
}
}
This approach works because the Web Component isn't connected to the DOM yet when its attributes are assigned. The browser won't run the custom element's constructor or its connectedCallback() until the element is actually appended. By that moment, all required attributes are present and populated with valid values. From the widget's perspective, it initializes in a fully configured state.
Another important detail: Angular no longer participates in the element's lifecycle. It doesn't create the element, doesn't attach it, and doesn't attempt to update its attributes later. You treat the Web Component as an external system and interact with it directly, which eliminates the timing conflict between Angular's rendering cycle and the browser's custom element initialization.
When you leave the route, the element is destroyed along with the rest of the DOM. When you come back, a new element is created, configured, and attached. There's no difference between the first and second render, and no chance for the Web Component to encounter undefined inputs during startup. You can find more of my writing on dev.to, connect with me on LinkedIn, or check out my personal website.
