Angular Components: Composition-Driven Forms Over Inheritance
Angular Components: Composition-Driven Forms Over Inheritance In Object-Oriented Programming (OOP), Composition (Has-A) is a key concept, alongside Inheritance (Is-A). In Angular, we often use composition naturally when we inject services into components - services that handle API requests, caching, etc. However, inheritance - extending a BaseComponent - is also common in Angular. One of the main strengths of Inheritance is shared behavior. It works well in many cases, but sometimes it creates a subtle problem: When the base component starts holding form-related logic, it forces every child component (even those that only display data, not create or update data) to inherit logic that they might not need. This can lead to tightly-coupled components. Keeping the base component simple and minimal is a valid OOP approach. For example, a BaseComponent might look like this: // src/app/shared/component/base.component.ts @Component({ selector: '', template: '', changeDetection: ChangeDetectionStrategy.OnPush }) export class BaseComponent { private errorMsg: WritableSignal = signal(''); private statusMsg: WritableSignal = signal(''); private ready: WritableSignal = signal(false); protected componentReady(): void { this.ready.set(true); } protected resetErrorMessage(): void { this.errorMsg.set(''); } protected resetStatusMessage(): void { this.statusMsg.set(''); } protected updateStatusMessage(message: string = ''): void { this.statusMsg.set(message); } protected updateErrorMessage(message: string = ''): void { this.errorMsg.set(message); } public get isComponentReady(): boolean { return this.ready(); } public get errorMessage(): string { return this.errorMsg(); } public get statusMessage(): string { return this.statusMsg(); } } Reusing logic through inheritance like this is perfectly fine - when done carefully. However, when it comes to handling forms, a cleaner and more scalable approach is to compose form functionality inside components, rather than inheriting it. In this guide, we'll see how to: Create a domain model (ChatRoom) Build a reusable form (ChatRoomForm) Define a component interface (HasForm similar to OnInit) Create a BaseForm class that can be extended by form-related classes. Section 1: Creating ChatRoom Let's define a simple domain model for creating or updating a chat room. // src/app/model/domain/chat-room.ts export class ChatRoom { public readonly id: number | string; public readonly title: string; public readonly description: string; public readonly tags: string; public readonly guidelinesOrRules: string; public readonly visibility: ChatRoomVisibility; public constructor(data: ChatRoom) { this.id = data.id ?? 0; this.title = data.title ?? ''; this.description = data.description ?? ''; this.tags = data.tags ?? ''; this.guidelinesOrRules = data.guidelinesOrRules ?? ''; this.visibility = data.visibility ?? ''; } public static of(data: ChatRoom): ChatRoom { return new ChatRoom(data); } public static empty(): ChatRoom { return new ChatRoom({} as ChatRoom); } } Section 2: CreatingBaseForm Now, let's define the base form as follows: // src/app/model/form/base.form.ts export abstract class BaseForm { protected formGroup!: FormGroup; private submitting: WritableSignal = signal(false); private formReady: WritableSignal = signal(false); private formCompleted: WritableSignal = signal(false); protected constructor() {} protected initForm(): void {} protected control(name: string): AbstractControl | null | undefined { return this.form?.get(name); } public enableFormComplete(): void { this.formCompleted.set(true); } public disableFormCompleted(): void { this.formCompleted.set(false); } public openForm(): void { this.formReady.set(true); } public startSubmitting(): void { this.submitting.set(true); } public stopSubmitting(): void { this.submitting.set(false); } public get form(): FormGroup { return this.formGroup; } public get value(): T { return this.form.value as T; } public get isFormValid(): boolean { return this.formGroup.valid; } public get isFormCompleted(): boolean { return this.formCompleted(); } public get isFormReady(): boolean { return this.formReady(); } public get isSubmitting(): boolean { return this.submitting(); } public get isNotSubmitting(): boolean { return !(this.isSubmitting); } } The formGroup contains your form. The submitting signal tracks your form's submission state. You can use it to prevent multiple submissions until the request is processed. The formReady signal helps you display the HTML form in the UI when initialization of the form with its control and data is completed. The formCompleted signal is optional. It helps you display UI animations or transitions a

Angular Components: Composition-Driven Forms Over Inheritance
In Object-Oriented Programming (OOP), Composition (Has-A) is a key concept, alongside Inheritance (Is-A).
In Angular, we often use composition naturally when we inject services into components - services that handle API requests, caching, etc.
However, inheritance - extending a BaseComponent
- is also common in Angular. One of the main strengths of Inheritance is shared behavior.
It works well in many cases, but sometimes it creates a subtle problem:
When the base component starts holding form-related logic, it forces every child component (even those that only display data, not create or update data) to inherit logic that they might not need. This can lead to tightly-coupled components.
Keeping the base component simple and minimal is a valid OOP approach.
For example, a BaseComponent
might look like this:
// src/app/shared/component/base.component.ts
@Component({
selector: '',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BaseComponent {
private errorMsg: WritableSignal<string> = signal('');
private statusMsg: WritableSignal<string> = signal('');
private ready: WritableSignal<boolean> = signal(false);
protected componentReady(): void {
this.ready.set(true);
}
protected resetErrorMessage(): void {
this.errorMsg.set('');
}
protected resetStatusMessage(): void {
this.statusMsg.set('');
}
protected updateStatusMessage(message: string = ''): void {
this.statusMsg.set(message);
}
protected updateErrorMessage(message: string = ''): void {
this.errorMsg.set(message);
}
public get isComponentReady(): boolean {
return this.ready();
}
public get errorMessage(): string {
return this.errorMsg();
}
public get statusMessage(): string {
return this.statusMsg();
}
}
Reusing logic through inheritance like this is perfectly fine - when done carefully.
However, when it comes to handling forms, a cleaner and more scalable approach is to compose form functionality inside components, rather than inheriting it.
In this guide, we'll see how to:
- Create a domain model (
ChatRoom
) - Build a reusable form (
ChatRoomForm
) - Define a component interface (
HasForm
similar toOnInit
) - Create a BaseForm class that can be extended by form-related classes.
Section 1: Creating ChatRoom
Let's define a simple domain model for creating or updating a chat room.
// src/app/model/domain/chat-room.ts
export class ChatRoom {
public readonly id: number | string;
public readonly title: string;
public readonly description: string;
public readonly tags: string;
public readonly guidelinesOrRules: string;
public readonly visibility: ChatRoomVisibility;
public constructor(data: ChatRoom) {
this.id = data.id ?? 0;
this.title = data.title ?? '';
this.description = data.description ?? '';
this.tags = data.tags ?? '';
this.guidelinesOrRules = data.guidelinesOrRules ?? '';
this.visibility = data.visibility ?? '';
}
public static of(data: ChatRoom): ChatRoom {
return new ChatRoom(data);
}
public static empty(): ChatRoom {
return new ChatRoom({} as ChatRoom);
}
}
Section 2: CreatingBaseForm
Now, let's define the base form as follows:
// src/app/model/form/base.form.ts
export abstract class BaseForm<T> {
protected formGroup!: FormGroup;
private submitting: WritableSignal<boolean> = signal(false);
private formReady: WritableSignal<boolean> = signal(false);
private formCompleted: WritableSignal<boolean> = signal(false);
protected constructor() {}
protected initForm(): void {}
protected control(name: string): AbstractControl | null | undefined {
return this.form?.get(name);
}
public enableFormComplete(): void {
this.formCompleted.set(true);
}
public disableFormCompleted(): void {
this.formCompleted.set(false);
}
public openForm(): void {
this.formReady.set(true);
}
public startSubmitting(): void {
this.submitting.set(true);
}
public stopSubmitting(): void {
this.submitting.set(false);
}
public get form(): FormGroup {
return this.formGroup;
}
public get value(): T {
return this.form.value as T;
}
public get isFormValid(): boolean {
return this.formGroup.valid;
}
public get isFormCompleted(): boolean {
return this.formCompleted();
}
public get isFormReady(): boolean {
return this.formReady();
}
public get isSubmitting(): boolean {
return this.submitting();
}
public get isNotSubmitting(): boolean {
return !(this.isSubmitting);
}
}
The formGroup
contains your form.
The submitting
signal tracks your form's submission state. You can use it to prevent multiple submissions until the request is processed.
The formReady
signal helps you display the HTML form in the UI when initialization of the form with its control and data is completed.
The formCompleted
signal is optional. It helps you display UI animations or transitions as feedback when the form submission is complete.
The control
method allows you to easily retrieve a form control from a form group without repetition of syntax. For example:
public get title(): AbstractControl | null | undefined {
return this.control('title');
}
public get description(): AbstractControl | null | undefined {
return this.control('description');
}
is simple compared to
public get title(): AbstractControl | null | undefined {
return this.formGroup?.get('title');
}
public get description(): AbstractControl | null | undefined {
return this.formGroup?.get('description');
}
The form's variable name might change, but using the control
method provides a single source of truth and the logic for retrieving a form control is encapsulated. When there is a change, you update only this method.
The getter
and other methods are a way of encapsulating the logic and behavior of the form and also giving you access to its state and data.
Section 3: CreatingChatRoomForm
Now, we will create ChatRoomForm
// src/app/model/form/chat-room.form.ts
export class ChatRoomForm extends BaseForm<CreateChatRoomPayload> {
private constructor(
private formBuilder: FormBuilder,
private chatRoom: ChatRoom) {
super();
}
public get title(): AbstractControl | null | undefined {
return this.control('title');
}
public get description(): AbstractControl | null | undefined {
return this.control('description');
}
public get guidelines(): AbstractControl | null | undefined {
return this.control('guidelinesOrRules');
}
public get tags(): AbstractControl | null | undefined {
return this.control('tags');
}
public get visibility(): AbstractControl | null | undefined {
return this.control('visibility');
}
public get visibilities(): string[] {
return Object.values(ChatRoomVisibility);
}
protected override initForm(): void {
this.formGroup = this.formBuilder.group({
title: [this.chatRoom.title, [
required,
minLength(10),
maxLength(500),
]],
description: [this.chatRoom.description, [
required,
maxLength(1000),
]],
tags: [this.chatRoom.tags, [
required,
minLength(10),
maxLength(500),
]],
guidelinesOrRules: [this.chatRoom.guidelinesOrRules, [
required,
maxLength(1500),
]],
visibility: [this.chatRoom.visibility, [
required,
oneOf(ChatRoomVisibility)
]],
});
}
public static of(formBuilder: FormBuilder, chatRoom: ChatRoom): ChatRoomForm {
const chatRoomForm: ChatRoomForm = new ChatRoomForm(formBuilder, chatRoom);
chatRoomForm.initForm();
return chatRoomForm;
}
public static empty(formBuilder: FormBuilder): ChatRoomForm {
return new ChatRoomForm(formBuilder, ChatRoom.empty());
}
}
The ChatRoomForm
has a private constructor since it's not used in dependency injection. Instead, it's created via the static of
method, following the factory pattern.
Using validators this way reduces repetition of the Validators class and supports polymorphic implementations. You can introduce custom validators, and changes apply instantly without updating the entire codebase. For example, you can define validators like this:
// src/app/shared/validators/index.ts
export const required = Validators.required;
export const minLength = Validators.minLength;
export const maxLength = Validators.maxLength;
Learn more about How to Organize Validators in an Angular app.
Section 4: Creating HasForm
Interface
The HasForm
interface, for components requiring a user-filled form, looks like this:
// src/app/model/interface/form/has-form.interface.ts
export interface HasForm {
formReady(): void;
initForm(data?: any): void;
startSubmitting(): void;
stopSubmitting(): void;
completeForm(): void;
get formModel(): BaseForm<any>;
get payload(): any;
get isFormReady(): boolean;
get isFormCompleted(): boolean;
get isFormValid(): boolean
get isNotSubmitting(): boolean;
get isSubmitting(): boolean;
}
These getter methods encapsulate the actual form behavior and logic and will be used in the UI and some part of your component.
These behaviors were usually implemented in the BaseComponent
before and then inherited by extending components. This isn't always ideal as components like ChatRoomItem
and ChatRoomList
typically display data without mutating or updating it.
You can adapt this interface to work with your existing component design patterns and architecture.
The CreateChatRoom
component looks like
@Component({
selector: 'app-create-chat-room',
imports: [
ValidationErrorComponent,
ReactiveFormsModule,
FaIconComponent,
],
providers: [
ChatRoomService,
],
templateUrl: './create-chat-room.html',
styleUrl: './create-chat-room.css'
})
export class CreateChatRoom extends BaseComponent implements OnInit, HasForm {
protected readonly chatRoomService: ChatRoomService = inject(ChatRoomService);
protected readonly formBuilder: FormBuilder = inject(FormBuilder);
protected readonly chatRoomForm: WritableSignal<ChatRoomForm> = signal(ChatRoomForm.empty(this.formBuilder));
public ngOnInit(): void {
this.initForm();
}
public formReady(): void {
this.formModel.openForm();
this.componentReady();
}
public completeForm(): void {
this.formModel.completeForm();
}
public initForm(): void {
const createChatRoomForm: ChatRoomForm = ChatRoomForm.of(this.formBuilder, ChatRoom.empty());
this.chatRoomForm.set(createChatRoomForm);
this.formReady();
}
public createChatRoom(): void {
if (this.isFormValid && this.isNotSubmitting) {
this.startSubmitting();
const payload: CreateChatRoomPayload = this.payload;
this.chatRoomService.create(payload)
.subscribe({
next: (response: CreateChatRoomResponse): void => { this.createChatRoomSuccess(response); },
error: (error: ErrorResponse): void => { this.createChatRoomFailure(error); },
complete: (): void => { this.createChatRoomComplete(); }
});
}
}
protected createChatRoomSuccess(result: CreateChatRoomResponse): void {
this.updateStatusMessage(result.message);
}
protected createChatRoomFailure(error: ErrorResponse): void {
this.updateErrorMessage(error.message);
this.createChatRoomComplete();
}
protected createChatRoomComplete(): void {
this.stopSubmitting();
this.completeForm();
}
public startSubmitting(): void {
this.formModel.startSubmitting();
}
public stopSubmitting(): void {
this.formModel.stopSubmitting();
}
public get createChatRoomForm(): FormGroup {
return this.formModel.form;
}
public get payload(): CreateChatRoomPayload {
return this.formModel.value;
}
public get formModel(): ChatRoomForm {
return this.chatRoomForm();
}
public get isFormReady(): boolean {
return this.formModel.isFormReady;
}
public get isFormValid(): boolean {
return this.formModel.isFormValid;
}
public get isFormCompleted(): boolean {
return this.formModel.isFormCompleted;
}
public get isSubmitting(): boolean {
return this.formModel.isSubmitting;
}
public get isNotSubmitting(): boolean {
return this.formModel.isNotSubmitting;
}
protected readonly faCheck = faCheck;
protected readonly faSpinner = faSpinner;
protected readonly faPlus = faPlus;
}
The signal(ChatRoomForm.empty())
initialize the signal with a form but whose state would not be ready, the UI and code is guarded and protected with the isFormReady check in the HTML template.
@if (isFormReady) {
}
The initForm
method creates a new ChatRoomForm
using static of method, initialize the form with opens the form and ready to be interacted with and then return it.
The openForm
method sets the formReady
signal state to true
which indicates initialization is complete and the form is ready to be interacted with or completed by the user.
The payload
getter method returns the form value you will use to create the chat room. Its type
looks like this
export type CreateChatRoomPayload = {
title: string;
description: string;
guidelinesOrRules: string;
tags: string;
visibility: ChatRoomVisibility | string;
}
The payload getter method allows you to preprocess or add other details before the data is used. For example
public get payload(): CreateLiveStreamPayload {
const data: CreateLiveStreamPayload = this.formModel.value;
return {
...data,
startDateTime: addSecondsToDate(this.formModel.startDateTimeValue),
endDateTime: addSecondsToDate(this.formModel.endDateTimeValue),
};
}
Section 5: HTML Template
@if (isComponentReady && isFormReady) {
}
You use the getter methods to enhance the UI and form logic.
Addition:
What about the completeForm()?
Earlier, I mentioned how the formCompleted
signal aids UI animations. The implementation is as follows:
protected formCompleted: WritableSignal<boolean> = signal(false);
The implementation in the BaseForm will look something like this:
public completeForm(delayToReset: number = 5000): void {
// Ensure delay is non-negative
const safeDelay: number = Math.max(0, delayToReset);
// Mark the form as completed
this.enableFormComplete();
// Set a timer to reset form completion status
timer(safeDelay).pipe(
// Reset form completion status
tap(() => this.disableFormCompleted()),
).subscribe();
}
When the user's form submission is completed and the completeForm()
is called, the Font Awesome animation starts as indicated here
@if (isFormCompleted) {<fa-icon [icon]="faCheck"/>}
After the 5 seconds default delay, the status is reset to false
and the check or mark icon disappears.
Note:
Of course, the methods in HasForm
have already been defined in the formModel
or BaseForm
. It's debatable whether this introduces over-abstraction. You can decide to implement all the methods in HasForm or you can remove some and access the formModel
in the Component code or directly in the HTML template.
Repository: Angular Form Composition
Using composition leads to better separation of concerns, easier testing, and more reusable components. I hope you enjoy it.