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);
}
}
}
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
}
}
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 {}
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);
}
}
}
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',
},
]);
}
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();
}
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 : '' }`;
})
}
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;
}
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>
@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;
}
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)
});
}
}
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));
}
}
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>
devMeService = inject(DevMePlaceholderService);
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>
picsumService = inject(PicsumPlaceholderService);
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:
- Directive Composition API: https://angular.dev/guide/directives/directive-composition-api
- https://www.angularspace.com/directive-composition-api/
- Stackblitz Demo:
- Not DRY solution - One component for each placeholder category:
- https://stackblitz.com/edit/stackblitz-starters-cfkwzwtd?file=src%2Fmain.ts
- DRY solution - create a reusable component that renders placeholder for different categories:
- https://stackblitz.com/edit/stackblitz-starters-zf1gynyl?file=src%2Fmain.ts
- Build reusable component for different placeholder sites and buttons based on signals and Directive Composition API: https://stackblitz.com/edit/stackblitz-starters-fzanbjsw?file=src%2Fmain.ts
Top comments (0)