DEV Community

Connie Leung
Connie Leung

Posted on

Built DRY components with signal and Directive Composition API in Angular

I want to demonstrate the growing plan that junior Angular developers endure when learning to build DRY (Don’t repeat yourself) components and applications that scale.

First, I showed a demo not DRY, where developers duplicated the components to display image placeholders for different categories. Next, I refactored the demo to create a reusable component that accepts a category to display different image placeholders. As a result, the demo eliminated duplicated codes and components. Finally, I refactored the demo using Directive Composition API and content projection to display image placeholders from different sites and project loader buttons.

Not a DRY solution and duplicated components

Image Placeholder Components for Six Categories

https://dev.me/products/image-placeholder can generate image placeholders for six categories: game, album, movie, furniture, watch, and shoe. These URLs have a fixed format: https://via.assets.so/.png?q=95&w=360&h=360&id=. The valid values for the category are game, album, movie, furniture, watch, and shoe, and the ID is an integer.

The naive way is to create a component for each category. For example, the following is the implementation of an album image placeholder.

@Component({
 selector: 'app-album-placeholder',
 imports: [NgOptimizedImage],
 templateUrl: './placeholder.component.html',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppAlbumPlaceholderComponent {
 id = signal(1);

 url = computed(() => `https://via.assets.so/album.png?id=${this.id()}&q=95&w=360&h=360`);

 increment() {
   this.id.update((prev) => prev % 50 + 1);
 }

 decrement() {
   const value = this.id();
   if (value <= 1) {
     this.id.set(50);
   } else {
     this.id.update((prev) => (prev - 1) % 50);
   }
 }
}
Enter fullscreen mode Exit fullscreen mode

The other components have identical codes, except the URL is different.

@Component({
 selector: 'app-furniture-placeholder',
 imports: [NgOptimizedImage],
 templateUrl: './placeholder.component.html',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppFurniturePlaceholderComponent {
 id = signal(1);
 url = computed(() => `https://via.assets.so/furniture.png?id=${this.id()}&q=95&w=200&h=200`);

 increment() {
   // same code
 }

 decrement() {
   // same code
 }
}
Enter fullscreen mode Exit fullscreen mode

App Component

@Component({
 selector: 'app-root',
 imports: [AppGamePlaceholderComponent, AppAlbumPlaceholderComponent, AppMoviePlaceholderComponent, AppShoePlaceholderComponent,
 AppFurniturePlaceholderComponent,
 AppWatchPlaceholderComponent],
 template: `
   <div class="wrapper">
     <app-game-placeholder />
     <app-album-placeholder />
     <app-movie-placeholder />
     <app-shoe-placeholder />
     <app-furniture-placeholder />
     <app-watch-placeholder />
   </div>
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

The App component imports the components and displays them in the inline template. The solution is not DRY.

  • The id signal, increment, and decrement methods are duplicated in the component
  • All the components must be updated when the logic or URL changes. The process is tedious and error-prone. Developers can easily miss a place or two when making code changes.

However, I converted the image placeholder components to accept a category input to make a reusable component. Then, I removed the redundant one to keep the code clean and maintainable.

Create a reusable Image Placeholder Component

@Component({
 selector: 'app-placeholder',
 imports: [NgOptimizedImage],
 templateUrl: './placeholder.component.html',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppPlaceholderComponent {
 id = signal(1);
 subpath = input.required({ alias: 'category'});

 url = computed(() => `https://via.assets.so/${this.subpath()}.png?id=${this.id()}&q=95&w=360&h=360`);

 increment() {
   this.id.update((prev) => prev % 50 + 1);
 }

 decrement() {
   const value = this.id();
   if (value <= 1) {
     this.id.set(50);
   } else {
     this.id.update((prev) => (prev - 1) % 50);
   }
 }
}
Enter fullscreen mode Exit fullscreen mode

The AppPlaceholderComponent component accepts a subPath input signal, which is the category of the image placeholder.

App Component

@Component({
 selector: 'app-root',
 imports: [AppPlaceholderComponent],
 template: `
   <div class="wrapper">
     @for (placeholder of placeholders(); track placeholder.category) {
       <app-placeholder [category]="placeholder.category" />
     }
   </div>
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
 placeholders = signal([
   {
     category: 'game',
   },
   {
     category: 'album',
   },
   {
     category: 'movie',
   },
   {
     category: 'shoe',
   },
   {
     category: 'furniture',
   },
   {
     category: 'watch',
   },
 ]);
}
Enter fullscreen mode Exit fullscreen mode

The App component imports AppPlaceholderComponent component and uses it in the inline template. The template iterates the placeholders signal and passes the category to the component to render. This demo achieves the same result with fewer codes. It is because I got rid of the AppAlbumPlaceholderComponent, AppMoviePlaceholderComponent, AppShoePlaceholderComponent, AppFurniturePlaceholderComponent, AppWatchPlaceholderComponent, and AppGamePlaceholderComponent and replaced them with AppPlaceholderComponent.

This solution is DRY but is tightly coupled to the image placeholders of https://via.assets.so. What if I want to render the image placeholders of https://picsum.photos? What if I want to show a button that generates a random ID instead of two buttons that decrement and increment the ID by 1? Do I make new components for the picsum.photos site? My initial response was "Yes" but I changed it to "No" after applying the Directive Composition API and host directives to the placeholder components.

Directive Composition API

Implement the Placeholder Loader directive

I added new directives and an AppPlaceholderButton component to use the Directive Composition API.

@Directive({
 host: {
   '[class]': 'classes()',
 }
})
export class PlaceholderLoaderDirective {
 variant = input<'primary' | 'secondary'>('primary');
 type = input<'basic' | 'soft'>('basic');

 classes = computed(() => [this.type(), this.variant()]);
 loadPlaceholder = output();
}
Enter fullscreen mode Exit fullscreen mode

This directive exposes two input signals, variant anad type, to apply the CSS classes to the host button and emits a loadPlaceholder custom event when clicked. The selector of this directive is optional because it will be used as the host directive of the component.

Implement the Image URL directive

@Directive()
export class ImageUrlDirective {
 baseUrl = input.required<string>();
 urlParam = input('');

 url = computed(() => {
   const param = this.urlParam();
   return `${this.baseUrl()}${param ? '?' + param : '' }`;
 })
}
Enter fullscreen mode Exit fullscreen mode

This directive exposes the two inputs, baseUrl and urlParam, that compose the full image URL. The url computed signal concatenates the two inputs to form the image URL. This directive is designed to be the host directive of the AppPlaceholderComponent.

The host Button Component

@Component({
 selector: 'app-placeholder-button',
 template: `
   <button (click)="loadPlaceholder.emit()"><ng-content /></button> 
 `,
 styleUrl: './app-placeholder-button.component.scss',
 hostDirectives: [
   {
     directive: PlaceholderLoaderDirective,
     inputs: ['variant', 'type'],
     outputs: ['loadPlaceholder']
   }
 ],
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppPlaceholderButtonComponent {
 #directive = inject(PlaceholderLoaderDirective);
 loadPlaceholder = this.#directive.loadPlaceholder;
} 
Enter fullscreen mode Exit fullscreen mode

This button component imports the PlaceholderLoaderDirective directive in the hostDirective array, which exposes the inputs and output. The component injects the PlaceholdeLoaderDirective directive and emits the loadPlaceholder event when clicking the button.

Refactor the AppPlaceholderComponent

<div>
 <div>
   <img [ngSrc]="url()" alt="Image placeholder" priority width="200" height="200" />
 </div>
 <ng-content buttons>Put your loader button(s) here</ng-content>
</div>
Enter fullscreen mode Exit fullscreen mode
@Component({
 selector: 'app-placeholder',
 imports: [NgOptimizedImage],
 hostDirectives: [
   {
     directive: ImageUrlDirective,
     inputs: ['baseUrl', 'urlParam']
   }
 ],
 templateUrl: './placeholder.component.html',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppPlaceholderComponent {
 url = inject(ImageUrlDirective).url;
}
Enter fullscreen mode Exit fullscreen mode

The component imports the ImageUrlDirective in the hostDirectives array. It injects the ImageUrlDirective and uses the url computed signal as the source of the image. Moreover, the HTML template replaces the HTML buttons with <ng-content> so I could project AppPlaceholderButtonComponent instead.

Add Image Placeholder services

const NUM_PLACEHOLDERS = 50;
const PLACEME_CATEGORIES = [
 'game.png',
 'album.png',
 'movie.png',
 'shoe.png',
 'furniture.png',
 'watch.png',
];

@Injectable({
 providedIn: 'root'
})
export class DevMePlaceholderService {
 #ids = signal([1, 1, 1, 1, 1, 1]);
 placeholders = computed<Placeholder[]>(() => {
   return this.#ids().map((value, index) => ({
     id: index + 1,
     url: `https://via.assets.so/${PLACEME_CATEGORIES[index]}`,
     urlParam: `q=95&w=200&h=200&id=${value}`,
   }))
 });

 increment(index: number) {
   this.#ids.update((prev) =>
     prev.map((item, i) => i === index ? item % NUM_PLACEHOLDERS + 1 : item)
   );
 }

 decrement(index: number) {
   const value = this.#ids()[index];
   const nextValue = value <= 1 ? NUM_PLACEHOLDERS : (value - 1) % NUM_PLACEHOLDERS;
   this.#ids.update((prev) => {
     return prev.map((item, i) => i === index ? nextValue : item)
   });
 }
}
Enter fullscreen mode Exit fullscreen mode

This service populates the image placeholder URLs of the https://dev.me/products/image-placeholder site. The methods increment or decrement the image ID.

@Injectable({
 providedIn: 'root'
})
export class PicsumPlaceholderService {
 #randoms = signal([1, 2, 3]);
 placeholders = computed<Placeholder[]>(() => {
   return this.#randoms().map((value, index) => {
     let urlParam = '';
     if (index === 1) {
       urlParam = 'grayscale';
     } else if (index === 2) {
       urlParam = 'blur=2';
     }
     return {
       id: index + 1,
       url: `https://picsum.photos/id/${value}/200.jpg`,
       urlParam,
     };
   });
 });

 load(index: number) {
   const value = Math.floor((Math.random() * 19) + 1);
   this.#randoms.update((prev) => prev.map((item, i) => i == index ? value: item));
 }
}
Enter fullscreen mode Exit fullscreen mode

This service populates the image placeholder URLs of the https://picsum.photos site, and there is one method that randomizes the image ID between 1 and 20.

Build the image placeholders for both sites

<div class="wrapper">
     @for (placeholder of devMeService.placeholders(); track placeholder.id; let isOdd=$odd; let index = $index) {
       @let variant = isOdd ? 'primary': 'secondary';
       <app-placeholder [baseUrl]="placeholder.url" [urlParam]="placeholder.urlParam">
         <app-placeholder-button buttons [variant]="variant"
           (loadPlaceholder)="devMeService.decrement(index)"
           style="margin-right: 0.75rem;"
         >
           Previous
         </app-placeholder-button>
         <app-placeholder-button buttons [variant]="variant" (loadPlaceholder)="devMeService.increment(index)">
           Next
         </app-placeholder-button>
       </app-placeholder>
     }
</div>
Enter fullscreen mode Exit fullscreen mode
devMeService = inject(DevMePlaceholderService);
Enter fullscreen mode Exit fullscreen mode

The App component injects the DevMePlaceholderService service to pass the URLs and parameters to the AppPlaceholderComponent to render the image placeholders. Then, two AppPlaceholderButtonComponent components are projected to display the buttons to load the previous and next images when clicked.

<div class="wrapper">
     @for (placeholder of picsumService.placeholders(); track placeholder.id; let isOdd=$odd; let index = $index) {
       @let variant = isOdd ? 'primary': 'secondary';
       <app-placeholder [baseUrl]="placeholder.url" [urlParam]="placeholder.urlParam">
         <app-placeholder-button buttons type='soft' [variant]="variant"
           (loadPlaceholder)="picsumService.load(index)"
         >
           Load
         </app-placeholder-button>
       </app-placeholder>
     }
 </div>
Enter fullscreen mode Exit fullscreen mode
picsumService = inject(PicsumPlaceholderService);
Enter fullscreen mode Exit fullscreen mode

The App component injects the PicsumPlaceholderService service to pass the URLs and parameters to the AppPlaceholderComponent to render the picsum.photos’ images. Then, an AppPlaceholderButtonComponent component is projected to display a button that loads a random image when clicked.

I have shown that Directive Composition API and content projection enable reusable functionality and dynamic content rendering. The demo can scale to show other image placeholders by passing the image URLs and parameters to component inputs and projecting loader buttons.

References:

Top comments (0)