Logo

dev-resources.site

for different kinds of informations.

Future-Proofing Components: The Power of Composable Components

Published at
12/14/2024
Categories
software
frontend
framework
designpatterns
Author
studiospindle
Author
13 person written this
studiospindle
open
Future-Proofing Components: The Power of Composable Components

As web application developers, we constantly seek ways to build more robust, scalable, and maintainable applications. While there are multiple approaches to structuring a component, I want to make a case for the composable component pattern, which offers significant advantages in many scenarios.

This design philosophy emphasizes building applications by assembling smaller, reusable components rather than creating monolithic structures. By leveraging composability, developers can enhance code readability, testability, and scalability while adhering to best practices in software engineering.

The Evolution of Component Design

Initial Approach: Simple Component

Initially, developers often create straightforward components to handle specific tasks. For instance, a basic user registration form might look like this:

/**
 * Note: The examples in this article are for angular, 
 *       but this pattern can be used in any framework.
 */
@Component({
  selector: 'app-user-registration',
  template: `
    <form (ngSubmit)="submitForm($event)">
      <div>
        <label>Full Name</label>
        <input
          type="text"
          name="fullName"
          placeholder="Enter your full name" />
      </div>
      <div>
        <label>Email</label>
        <input
          type="email"
          name="email"
          placeholder="Enter your email" />
      </div>
      <div>
        <label>Password</label>
        <input
          type="password"
          name="password"
          placeholder="Enter your password" />
      </div>
      <button type="submit">Register</button>
    </form>
  `
})
export class UserRegistrationComponent { ... }
Enter fullscreen mode Exit fullscreen mode

As applications grow, you typically create more reusable components. Components that use input parameters for the different use cases.

This leads to the creation of configurable components:

@Component({
  selector: 'app-form',
  template: `
    <form (ngSubmit)="submitForm($event)">
      @for (field of formFields; track field.name) {
        <div>
          <label>{{ field.label }}</label>
          <input 
            [type]="field.type" 
            [name]="field.name" 
            [placeholder]="field.placeholder"
            [(ngModel)]="formData[field.name]"
          />
        </div>
      }
      <button type="submit">Submit</button>
    </form>
  `
})
export class FormComponent {
  @Input() formFields!: Array<{label: string, type: string, name: string, placeholder: string}>;
  @Output() formSubmit = new EventEmitter<Record<string, string>>();

  formData: Record<string, string> = {};

  submitForm() {
    this.formSubmit.emit(this.formData);
  }
}
Enter fullscreen mode Exit fullscreen mode

This configurable component can be used for different forms.

First the revised user registration form:

@Component({
  selector: 'app-user-registration',
  template: `<app-form [formFields]="registerFormFields" (formSubmit)="submit()" />`
})
export class UserRegistrationComponent {
  registerFormFields = [
      { label: 'Full Name', type: 'text', name: 'fullName', placeholder: 'Enter your full name' },
      { label: 'Email', type: 'email', name: 'email', placeholder: 'Enter your email' },
      { label: 'Password', type: 'password', name: 'password', placeholder: 'Enter your password' }
  ];
  // ...submit method
}
Enter fullscreen mode Exit fullscreen mode

And second for example a demo form:

@Component({
  selector: 'app-demo-registration',
  template: `
    <app-form [formFields]="demoFormFields"></app-registration-form>
  `
})
export class DemoRegistrationComponent {
  demoFormFields = [
    { label: 'Full Name', type: 'text', name: 'fullName', placeholder: 'Enter your full name' },
    { label: 'Email', type: 'email', name: 'email', placeholder: 'Enter your email' },
    { label: 'Company', type: 'text', name: 'company', placeholder: 'Enter your company name' },
    { label: 'Role', type: 'text', name: 'role', placeholder: 'Enter your role' }
  ];
  // ...submit method
}
Enter fullscreen mode Exit fullscreen mode

But then comes the twist.

Imagine that for the first form, you need to add e-mail validation specific to that form.

You can add it to the main form as an input boolean property:

@Component({
  selector: 'app-form',
  template: `
    <form (ngSubmit)="submitForm()">
      @for (field of formFields; track field.name) {
        <div>
          <label>{{ field.label }}</label>
          <input 
            [type]="field.type" 
            [name]="field.name" 
            [placeholder]="field.placeholder"
          >
          @if(field.name === 'email' && validateEmailDomain) {
            <!-- * Email domain will be validated -->
          }
        </div>
      }
      <button type="submit">Submit</button>
    </form>
  `
})
export class FormComponent {
  @Input() formFields!: Array<{
    label: string;
    type: string;
    name: string;
    placeholder?: string;
  }>;
  // added exception
  @Input() validateEmailDomain = false;

  formData: Record<string, string> = {};

  submitForm() {
    if (this.validateEmailDomain) {
      // Perform email domain validation
    }
    if (this.includeSource) {
      this.formData['source'] = 'demo_registration';
    }
    console.log(this.formData);
  }
}
Enter fullscreen mode Exit fullscreen mode

This means you can turn it off or on using that property. Just one boolean right?

But then another curveball. A different user story requires you to have an additional field in the submit payload. This should only be present in the demo form.

More additional logic is added:

@Component({
  selector: 'app-form',
  template: `
    <form (ngSubmit)="onSubmit()">
      @for (field of formFields; track field.name) {
        <div>
          <label>{{ field.label }}</label>
          <input 
            [type]="field.type" 
            [name]="field.name" 
            [placeholder]="field.placeholder"
            [(ngModel)]="formData[field.name]"
          >
          @if (field.name === 'email' && validateEmailDomain) {
            <span>* Email domain will be validated</span>
          }
        </div>
      }
      <button type="submit">Submit</button>
    </form>
  `
})
export class FormComponent {
  @Input() formFields!: Array<{ label: string; type: string; name: string; placeholder?: string }>;
  @Input() validateEmailDomain = false;
  // added exception
  @Input() includeSource = false;

  formData: Record<string, string> = {};

  onSubmit() {
    if (this.validateEmailDomain) {
      // Perform email domain validation
    }
    if (this.includeSource) {
      this.formData['source'] = 'demo_registration';
    }
    console.log(this.formData);
  }
}
Enter fullscreen mode Exit fullscreen mode

As time passes, more exceptions are added, especially in large organizations with many developers. This approach can lead to overly complex components with numerous configuration options and edge cases.

A good rule of thumb is that the main purpose of the component becomes too diverse. It tries to solve too many things.

The power of Composable Components

Composable components offer a more flexible and maintainable solution. By breaking down forms into smaller, specialized components, developers can create more adaptable and easier-to-maintain code.

Let's see what this would look like. First for the registration form:

// Regular Registration Form
@Component({
  selector: 'app-user-registration',
  template: `
    <form (submit)="onSubmit()">
      <app-text-input label="Full Name" name="fullName" placeholder="Enter your full name" />
      <app-email-input 
        label="Email" 
        name="email" 
        placeholder="Enter your email"
        [validateDomain]="true"
      />
      <app-password-input label="Password" name="password" placeholder="Enter your password" />
      <button type="submit">Register</button>
    </form>
  `
})
Enter fullscreen mode Exit fullscreen mode

Doing so, immediately highlights that the logic of the e-mail validation should be placed in the e-mail input component. Making it both reusable and logically placed.

This approach adheres to the principle of separation of concerns, ensuring that email-related validation is handled where it belongs - in the email input component itself.

@Component({
  selector: 'app-email-input',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div>
      <label [for]="name">{{ label }}</label>
      <input 
        type="email" 
        [id]="name"
        [name]="name" 
        [placeholder]="placeholder" 
        [(ngModel)]="email"
        (ngModelChange)="onEmailChange($event)"
      >
      @if (validateDomain) {
        <span>* Email domain will be validated</span>
      }
    </div>
  `
})
export class EmailInputComponent {
  @Input() label: string;
  @Input() name: string;
  @Input() placeholder: string;
  @Input() validateDomain = false;
  @Output() emailChange = new EventEmitter<string>();

  email: string = '';

  onEmailChange(value: string) {
    this.emailChange.emit(value);
    if (this.validateDomain) {
      this.validateEmailDomain(value);
    }
  }

  private validateEmailDomain(email: string) {
    // Implement domain validation logic here
    console.log(`Validating email domain for: ${email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Then for the demo registration form:

@Component({
  selector: 'app-demo-registration',
  template: `
    <form (ngSubmit)="onSubmit()">
      <app-text-input label="Full Name" name="fullName" placeholder="Enter your full name" />
      <app-email-input label="Email" name="email" placeholder="Enter your email" />
      <app-text-input label="Company" name="company" placeholder="Enter your company name" />
      <app-text-input label="Role" name="role" placeholder="Enter your role" />
      <button type="submit">Register for Demo</button>
    </form>
  `
})
export class DemoRegistrationComponent {
  @Output() formSubmit = new EventEmitter<Record<string, string>>();

  onSubmit() {
    // Gather form data
    const formData = {}; // Collect data from child components
    formData['source'] = 'demo_registration'; // Add source tracking
    this.formSubmit.emit(formData);
  }
}
Enter fullscreen mode Exit fullscreen mode

The composable approach does not only solve the immediate problems but also opens up new possibilities for creating flexible, maintainable forms across the entire application.

code duplication != knowledge duplication

One common argument against composable components is the perceived duplication of code. However, it's crucial to understand that code duplication is not knowledge duplication.

As stated so beautifully in 'The Pragmatic Programmer' by D. Thomas and A. Hunt:

"Don't Repeat Yourself (DRY) is about the duplication of knowledge, of intent."

Not about "don't copy-and-paste lines of source".

Consider the scenario of the configurable component: Two different forms in the application might be composed of the exact same set of smaller components. At first glance, this may seem like duplication. However, the forms serve different logical purposes - one is for user registration and the other for a demo.

The similarity in structure is merely coincidental. Each form represents a distinct concept in your domain model, and the reuse of components is a testament to their well-designed, modular nature.

Boolean inputs

Boolean inputs in a component can be a bad thing. In the first example of this article, it is. It is very similar to the code smell "Flag argument", as explained so well by Martin Fowler.

Summary

While the configurable approach offers flexibility and seems like it's DRY, it can lead to:

  • Increased complexity in templates
  • Difficulty in adding specific behaviors to individual fields
  • Challenges in maintaining type safety
  • Less intuitive component usage

The composable component offers several compelling advantages:

  • Flexibility: Easily modify one form without affecting the other
  • Readability: The structure of the form is clear and easy to understand at a glance, enhancing code comprehension.
  • Testability: Smaller, focused components are easier to unit test, leading to more robust code.
  • Separation of Concerns: Each component encapsulates its own logic and presentation, adhering to Angular's component-based architecture.
  • Scalability: Scale your application by composing new forms from existing components
framework Article's
30 articles in total
Favicon
Future-Proofing Components: The Power of Composable Components
Favicon
Introduction to Hono : Advantages, Disadvantages & FAQs
Favicon
Master Selenium Testing with Python: 5 Reasons to Use Pytest!
Favicon
Building a Robust Data Governance Framework: Best Practices and Key Considerations 
Favicon
Unlock Scalable Apps in 5 Minutes: Spring Reactive Revolutionizes Non-Blocking IO
Favicon
Looking for Contributors for Bloxtor: A Free Open-Source Web App Framework
Favicon
rs4j: Building a JNI Framework
Favicon
Revolutionize Cloud Development: Unlock the Full Potential of Spring WebFlux for Scalable and Efficient Applications
Favicon
Neo.mjs: A high-performance open-source JavaScript framework.
Favicon
Saba Framework: Mempermudah Pekerjaan Frontend dan Backend Developer
Favicon
DoDo Framework
Favicon
Complete Crash Course: Elysia.js Framework in Bangla - Build Scalable Apps - 2024
Favicon
Java Collections Scenario Based Interview Question
Favicon
3 Key Deliverables to Revolutionize Your IT Strategy Now
Favicon
Unlock Top 8 Selenium C# Frameworks for Lightning-Fast Automated Browser Testing
Favicon
Translation framework in Swift
Favicon
Handling custom error responses from ExpressoTS with TanStack Query, and NextJS
Favicon
How To Choose The Best Programming Framework For Your Needs
Favicon
Building a Deck-Building Site with ExpressoTS
Favicon
THE DIFFERENT BETWEEN LIBRARY AND FRAMEWORK AND NOT USING BOTH WITH REAL LIFE  ILLUSTRATIONS
Favicon
Framework – A platform for developing software applications.
Favicon
Cos’è l’approccio Document as Code (doc-as-code)
Favicon
ExpressoTS on The Rise???
Favicon
Transforming Business Processes with the Needle Framework: A Generative AI Solution
Favicon
When Developers Describe Their Code and Frameworks ⚡
Favicon
Come usare Java JMX in ambienti container
Favicon
LlamaIndex Framework - Context-Augmented LLM Applications
Favicon
LangChain - A Framework for LLM-Powered Applications
Favicon
Angular: Framework für Single Page Applications🌐
Favicon
Library v/s Framework

Featured ones: