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 { ... }
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);
}
}
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
}
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
}
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);
}
}
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);
}
}
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>
`
})
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}`);
}
}
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);
}
}
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
Top comments (0)