Angular Multiple Custom User Interface
In this post, we'll create an angular application that has many build configurations and each configuration has a different user interface but with the same business logic.
Let's say we already have an app. Then, our BMW client requests us to create a completely different user interface on the same app. To do this, we need to separate the business logic and view model from the UI component and then use the container component as a host to the UI component. The UI component is loaded based on the build configuration.

For example, a home page route has 2 inputs (first name and last name), an output (full name), and a save button. The full name combines values from first name and last name. The save button is disabled until both first name and last name are filled.
- home-page.bmw.component.ts (BMW UI component)
- home-page.component.ts (container component)
- home-page.default.component.ts (default UI component)
- home-page.service.ts (service)
home-page.service.ts contains the business logic and view model needed for the UI component.
// home-page.service.ts
import { Injectable } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { combineLatest, map, startWith, Subscription } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class HomePageService {
constructor() { }
private _subscription?: Subscription;
formGroup = new FormGroup({
firstName: new FormControl('', [Validators.required]),
lastName: new FormControl('', [Validators.required]),
fullName: new FormControl({
value: '',
disabled: true
}, [Validators.required])
});
vm$?: ReturnType<HomePageService['createViewModel']>;
init() {
this._subscription = new Subscription();
const firstNameValue$ = this.formGroup.controls.firstName.valueChanges.pipe(
startWith(this.formGroup.controls.firstName.value)
);
const lastNameValue$ = this.formGroup.controls.lastName.valueChanges.pipe(
startWith(this.formGroup.controls.lastName.value)
);
this._subscription.add(
combineLatest([
firstNameValue$,
lastNameValue$
]).subscribe(([firstName, lastName]) => {
const fullName = this.formGroup.controls.fullName;
const value = `${firstName} ${lastName}`.trim();
fullName.setValue(value);
})
);
this.vm$ = this.createViewModel();
}
reset() {
this._subscription?.unsubscribe();
this._subscription = undefined;
}
private createViewModel() {
const state$ = {
saveButtonDisabled: this.formGroup.statusChanges.pipe(
startWith(this.formGroup.status),
map((status) => status === 'INVALID')
)
};
const handler = {
saveButton: {
onClick: () => {
console.log('Save button clicked')
}
}
};
const vm$ = combineLatest(state$).pipe(
map((state) => ({
saveButton: {
disabled: state.saveButtonDisabled,
onClick: handler.saveButton.onClick
}
}))
);
return vm$;
}
}
home-page.component.ts serve as a container, using the ngComponentOutlet directive to load the UI component, and provide service for the UI component. contentType is loaded from environment which is different on each build configuration.
// home-page.component.ts
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { environment } from 'src/environments/environment';
import { HomePageService } from './home-page.service';
@Component({
selector: 'app-home-page',
standalone: true,
imports: [CommonModule],
providers: [HomePageService],
template: `
<ng-container *ngComponentOutlet="contentType"></ng-container>
`,
styles: [
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HomePageComponent implements OnInit, OnDestroy {
constructor(private service: HomePageService) { }
contentType = environment.uiComponents.homePage;
ngOnInit(): void {
this.service.init();
}
ngOnDestroy(): void {
this.service.reset();
}
}
The purpose of home-page.default.component.ts and home-page.bmw.component.ts is to render an HTML template. Business logic and view model came from service that is injected into the component. This component should not contain any logic in the template.
// home-page.default.component.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomePageService } from './home-page.service';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-home-page.default',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule
],
template: `
<ng-container *ngIf="service.vm$ | async as vm">
<h1>Default UI</h1>
<div>
<label for="name">First name: </label>
<input id="name" type="text" [formControl]="service.formGroup.controls.firstName">
</div>
<div>
<label for="name">Last name: </label>
<input id="name" type="text" [formControl]="service.formGroup.controls.lastName">
</div>
<div>
<label for="name">Full name: </label>
<input id="name" type="text" [formControl]="service.formGroup.controls.fullName">
</div>
<button [disabled]="vm.saveButton.disabled" (click)="vm.saveButton.onClick()">Save button</button>
</ng-container>
`,
styles: [
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HomePageDefaultComponent implements OnInit {
constructor(public service: HomePageService) { }
ngOnInit(): void {
}
}
// home-page.bmw.component.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomePageService } from './home-page.service';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-home-page.bmw',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule
],
template: `
<ng-container *ngIf="service.vm$ | async as vm">
<h1>BMW UI</h1>
<button [disabled]="vm.saveButton.disabled" (click)="vm.saveButton.onClick()">Save button</button>
<div>
<label for="name">Full name: </label>
<input id="name" type="text" [formControl]="service.formGroup.controls.fullName">
</div>
<p>Please fill form below</p>
<div class="row">
<div class="column">
<label for="name">First name: </label>
<input id="name" type="text" [formControl]="service.formGroup.controls.firstName">
</div>
<div class="column">
<label for="name">Last name: </label>
<input id="name" type="text" [formControl]="service.formGroup.controls.lastName">
</div>
</div>
</ng-container>
`,
styles: [
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HomePageBmwComponent implements OnInit {
constructor(public service: HomePageService) { }
ngOnInit(): void {
}
}
Below is the screenshot of the default build (http://localhost:4200/home) and the BMW build (http://localhost:4300/home).

For the source code, you can see it on my GitHub.