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

Apr 13, 2025 - 13:48
 0
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<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 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<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) {
   name="create-chat-room" method="post" enctype="application/x-www-form-urlencoded" autocomplete="off" [formGroup]="createChatRoomForm" (ngSubmit)="createChatRoom()">
    
for="title">Title*: type="text" id="title" name="title" formControlName="title" autocomplete="off"/> label="Title" [field]="formModel.title">
for="description">Description*: type="text" id="description" name="description" formControlName="description" autocomplete="off"/> label="Description" [field]="formModel.description">
type="submit" [disabled]="isSubmitting">Create @if (isFormCompleted) { [icon]="faCheck"/>} @else if (isSubmitting) { [icon]="faSpinner" [animation]="'spin'"/>} @else if (isNotSubmitting) { [icon]="faPlus"/>} }

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.