Angular Directive composition API + Signals = đź’›

Alexey Shlyk
15 min readMar 5, 2024

--

If you’re seeking a composable, testable, reactive, type-safe, and pure method to handle state in Angular applications, leveraging the Angular Directive composition API alongside Angular Signals might be a good choice.

My dog “Taiga” 🖤

Getting Started!

To validate these concepts, let’s build a simple TODO application. Our goals include:

  1. Fetching data from jsonplaceholder.typicode.com/todos— AdkHttpClient
  2. Enabling selection and deselection of TODOs with AdkSelection.
  3. Implementing pagination functionality with AdkPagination.
  4. Storing data effectively with AdkList.
  5. Emphasizing composition, reusability, and ease of testing with the Angular Directive composition API and Angular Signals.

Let’s initiate our project using the Angular CLI:

ng new todos --standalone --routing=true -s -t --style=scss --ssr=false --prefix=adk # adk - app dev kit

Throughout this project, we’ll use shared types for consistency and clarity:

type ID = string | number; // Flexible identifier for entities

type Identifiable = { // Common structure for identifiable entities
id: ID;
}

type Todo = Identifiable & { // Data structure for todos
title: string;
completed: boolean;
}

type Page = { // Structure for pagination
page: number;
limit: number;
}

We’ll proceed step by step, focusing on testing and making clear API boundaries for our directives. For hands-on experimentation, you can access the Stackblitz.

Each directive will be introduced with its signature, followed by test descriptions. Then I’ll write unit tests to make sure it works as expected. I believe it will help you understand the Angular Directive composition API and Signals and compare them with your approaches.

AdkList

AdkList is a directive designed to handle lists of entities. Let’s first take a look at its signature:

import { Directive, computed, signal } from '@angular/core';
import { ID, Identifiable } from '../models';

@Directive({
...
})
export class AdkList<T extends Identifiable> {
#items: WritableSignal<Record<ID, T>>;
readonly items: Signal<T[]>;

/**
* Get an item by id
* @param id
*/
get(id: ID): T {
...
}

/**
* Add new items to the list
* @param newItems
*/
add(...newItems: T[]): void {
...
}

/**
* Update an item in the list
* @param item
*/
update(item: T): void {
...
}

/**
* Remove an item from the list
* @param item
*/
remove(item: T): void {
...
}

/**
* Clear all items from the list
*/
clear(): void {
...
}
}

Let’s test the following scenarios:


describe('AdkList', () => {
it('should add items', () => {
...
});

it('should update items', () => {
...
});

it('should remove items', () => {
...
});

it('should clear items', () => {
...
});
});

Go to the test implementation:

import { Component } from '@angular/core';
import { AdkList } from './list';
import { TestBed } from '@angular/core/testing';
import { Todo } from '../models';

@Component({
standalone: true,
selector: 'adk-host',
template: ``,
hostDirectives: [AdkList],
})
class HostComponent {}

describe('AdkList', () => {
let list: AdkList<Todo>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
const fixture = TestBed.createComponent(HostComponent);
list = fixture.debugElement.injector.get(AdkList);
});

it('should add items', () => {
list.add({ id: 1, title: 'Todo 1', completed: false });
list.add({ id: 2, title: 'Todo 2', completed: false });
expect(list.items()).toEqual([
{ id: 1, title: 'Todo 1', completed: false },
{ id: 2, title: 'Todo 2', completed: false },
]);
});

it('should update items', () => {
list.add({ id: 1, title: 'Todo 1', completed: false });
list.update({ id: 1, title: 'Todo 1', completed: true });
expect(list.get(1)).toEqual({ id: 1, title: 'Todo 1', completed: true });
});

it('should remove items', () => {
list.add({ id: 1, title: 'Todo 1', completed: false });
list.add({ id: 2, title: 'Todo 2', completed: false });
list.remove({ id: 1, title: 'Todo 1', completed: false });
expect(list.items()).toEqual([
{ id: 2, title: 'Todo 2', completed: false },
]);
});

it('should clear items', () => {
list.add({ id: 1, title: 'Todo 1', completed: false });
list.add({ id: 2, title: 'Todo 2', completed: false });
list.clear();
expect(list.items()).toEqual([]);
});
});

And here’s the implementation of the directive itself:

import { Directive, computed, signal } from '@angular/core';
import { ID, Identifiable } from '../models';

@Directive({
selector: '[adk-list]',
exportAs: 'adkList',
standalone: true,
})
export class AdkList<T extends Identifiable> {
#items = signal<Record<ID, T>>({});
readonly items = computed(() => Object.values(this.#items()));

/**
* Get an item by id
* @param id
*/
get(id: ID): T | undefined {
return this.#items()[id];
}

/**
* Add new items to the list
* @param newItems
*/
add(...newItems: T[]): void {
this.#items.update((items) =>
newItems.reduce(
(accumulator, item) => ({ ...accumulator, [item.id]: item }),
items
)
);
}

/**
* Update an item in the list
* @param item
*/
update(item: T): void {
this.#items.update((items) => ({ ...items, [item.id]: item }));
}

/**
* Remove an item from the list
* @param item
*/
remove(item: T): void {
this.#items.update((items) => {
const { [item.id]: _, ...rest } = items;
return rest;
});
}

/**
* Clear all items from the list
*/
clear(): void {
this.#items.set({});
}
}

AdkSelection

AdkSelection is a directive to manage selections in a list. Let’s examine its signature first:

...

@Directive({
...
})
export class AdkSelection {
#items: WritableSignal<Record<ID, boolean>>;
count: Signal<number>;

/**
* Select multiple items
* @param ids
*/
select(...ids: ID[]): void {
...
}

/**
* Deselect an item
* @param id
*/
deselect(id: ID): void {
...
}

/**
* Clear all selected items
*/
clear(): void {
...
}

/**
* Check if an item is selected
* @param id
*/
selected(id: ID): boolean {
...
}
}

Let’s cover the test scenarios we want to address:

describe('AdkSelection', () => {
it('should select items', () => {
...
});

it('should deselect items', () => {
...
});

it('should clear items', () => {
...
});

it('should check if item is selected', () => {
...
});
});

Now, let’s look at the test implementation:

import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AdkSelection } from './selection';

@Component({
standalone: true,
selector: 'adk-host',
template: ``,
hostDirectives: [AdkSelection],
})
class HostComponent {}

describe('AdkSelection', () => {
let selection: AdkSelection;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
const fixture = TestBed.createComponent(HostComponent);
selection = fixture.debugElement.injector.get(AdkSelection);
});

it('should select items', () => {
selection.select(1, 2, 3);
expect(selection.count()).toBe(3);
});

it('should deselect items', () => {
selection.select(1, 2, 3);
selection.deselect(1);
expect(selection.count()).toBe(2);
});

it('should clear items', () => {
selection.select(1, 2, 3);
selection.clear();
expect(selection.count()).toBe(0);
});

it('should check if item is selected', () => {
selection.select(1, 2, 3);
expect(selection.selected(1)).toBe(true);
});
});

Here’s the implementation of the directive itself:

import { Directive, computed, signal } from '@angular/core';
import { ID } from '../models';

@Directive({
selector: '[adk-selection]',
exportAs: 'adkSelection',
standalone: true,
})
export class AdkSelection {
#items = signal<Record<ID, boolean>>({});
count = computed(() => Object.values(this.#items()).filter(Boolean).length);

/**
* Select multiple items
* @param ids
*/
select(...ids: ID[]): void {
this.#items.update((items) =>
ids.reduce((accumulator, id) => ({ ...accumulator, [id]: true }), items)
);
}

/**
* Deselect an item
* @param id
*/
deselect(id: ID): void {
this.#items.update((items) => {
const { [id]: _, ...rest } = items;
return rest;
});
}

/**
* Clear all selected items
*/
clear(): void {
this.#items.set({});
}

/**
* Check if an item is selected
* @param id
*/
selected(id: ID): boolean {
return this.#items()[id] ?? false;
}
}

AdkPagination

AdkPagination is a directive designed to facilitate pagination. Let’s explore its signature first:

...

@Directive({
...
})
export class AdkPagination {
#page: WritableSignal<number>;
readonly limit: WritableSignal<number>;
readonly total: WritableSignal<number>;

/**
* Check if the current page is the first
*/
first: Signal<boolean>;

/**
* Check if the current page is the last
*/
last: Signal<boolean>;

/**
* Go to the next page
*/
next(): void {
...
}

/**
* Go to the previous page
*/
previous(): void {
...
}
}

Let’s outline the test scenarios we want to cover:

describe('AdkPagination', () => {
it('should go to the next page', () => {
...
});

it('should go to the previous page', () => {
...
});

it('should throw an error if go to the previous page from the first page', () => {
...
});

it('should throw an error if go to the next page from the last page', () => {
...
});
});

Let’s write more tests:

import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AdkPagination } from './pagination';

@Component({
standalone: true,
selector: 'adk-host',
template: ``,
hostDirectives: [AdkPagination],
})
class HostComponent {}

describe('AdkPagination', () => {
let pagination: AdkPagination;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
const fixture = TestBed.createComponent(HostComponent);
pagination = fixture.debugElement.injector.get(AdkPagination);
});

it('should go to the next page', () => {
pagination.total.set(100);
pagination.next();
expect(pagination.page()).toBe(2);
});

it('should go to the previous page', () => {
pagination.total.set(100);
pagination.next();
pagination.previous();
expect(pagination.page()).toBe(1);
});

it('should throw an error if go to the previous page from the first page', () => {
expect(() => pagination.previous()).toThrowError(
'You are already on the first page'
);
});

it('should throw an error if go to the next page from the last page', () => {
pagination.total.set(10);
expect(() => pagination.next()).toThrowError(
'You are already on the last page'
);
});
});

And here’s the implementation:

import { Directive, computed, signal } from '@angular/core';

@Directive({
selector: '[adk-pagination]',
exportAs: 'adkPagination',
standalone: true,
})
export class AdkPagination {
#page = signal(1);
readonly page = computed(() => this.#page());
readonly limit = signal(10);
readonly total = signal(0);

/**
* Check if the current page is the first
*/
first = computed(() => this.#page() === 1);

/**
* Check if the current page is the last
*/
last = computed(() => this.#page() * this.limit() >= this.total());

/**
* Go to the next page
*/
next(): void {
if (this.last()) {
throw new Error('You are already on the last page');
}
this.#page.update((page) => page + 1);
}

/**
* Go to the previous page
*/
previous(): void {
if (this.first()) {
throw new Error('You are already on the first page');
}
this.#page.update((page) => page - 1);
}
}

AdkHttpClient

AdkHttpClient is a directive designed to handle HTTP calls. It could be improved by moving certain parts to services or even omitting them altogether. However, for our current needs, it suffices well. Let’s keep it simple.

Let’s take a look at its signature:

@Directive({
...
})
export class AdkHttpClient {
...
/**
* The URL to send the request to
*/
@Input({ required: true, alias: 'adkUrl' }) url!: string;
/**
* The page to get data from
*/
@Input({ alias: 'adkPage' }) page: number = 1;
/**
* The number of items to get
*/
@Input({ alias: 'adkLimit' }) limit: number = 10;

/**
* Get data from the server
* @param page
*/
async get<T>(
page: Page = { page: 1, limit: 10 }
): Promise<{ total: number; items: T }> {
...
}
}

Now, let’s cover the test scenario we want to address:

describe('AdkHttpClient', async () => {
it('should fetch data', async () => {
...
});
});

Let’s simulate the request to data:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdkHttpClient } from './http-client';
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { Component } from '@angular/core';

@Component({
standalone: true,
selector: 'adk-host',
template: ``,
hostDirectives: [{ directive: AdkHttpClient, inputs: ['adkUrl'] }],
})
class HostComponent {}

describe('AdkHttpClient', async () => {
let client: AdkHttpClient;
let fixture: ComponentFixture<HostComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
imports: [HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
client = fixture.debugElement.injector.get(AdkHttpClient);
fixture.componentRef.setInput(
'adkUrl',
'https://jsonplaceholder.typicode.com/todos'
);
});

it('should fetch data', async () => {
const promise = client.get();

const controller = TestBed.inject(HttpTestingController);
controller
.expectOne(
'https://jsonplaceholder.typicode.com/todos?_page=1&_per_page=10'
)
.flush([{ id: 1, title: 'Todo 1', completed: false }], {
headers: { 'X-Total-Count': '1' },
});

controller.verify();
expect(await promise).toEqual({
items: [{ id: 1, title: 'Todo 1', completed: false }],
total: 1,
});
});
});

The implementation, much like the previous ones, is straightforward and uncomplicated. However, one notable difference is the presence of the required input adkUrl. I'll demonstrate how it interacts with typings in an IDE later on.

Let's prefix our inputs to prevent any potential naming conflicts.

For now, let's focus on the primary task: sending requests to the API:

import { HttpClient } from '@angular/common/http';
import { Directive, Input, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Page } from '../models';

@Directive({
selector: '[adk-http-client]',
exportAs: 'adkHttpClient',
standalone: true,
})
export class AdkHttpClient {
#http = inject(HttpClient);
/**
* The URL to send the request to
*/
url = input.required<string>({ alias: 'adkUrl' });
/**
* The page to get data from
*/
page = input(1, { alias: 'adkPage' });
/**
* The number of items to get
*/
limit = input(10, { alias: 'adkLimit' });

/**
* Get data from the server
* @param page
*/
async get<T>(
page: Page = { page: 1, limit: 10 }
): Promise<{ total: number; items: T }> {
const searchParams = new URLSearchParams({
_page: page.page.toString(),
_per_page: page.limit.toString(),
}).toString();

const response = await firstValueFrom(
this.#http.get<T>(`${this.url}?${searchParams}`, {
observe: 'response',
})
);
const total = parseInt(response.headers.get('X-Total-Count') ?? '0', 10);
const items = response.body!;

return { total, items };
}
}

AdkTodos

And there you have it! Now we can integrate all our basic directives seamlessly:

import { Directive, inject } from '@angular/core';
import {
AdkList,
AdkSelection,
AdkHttpClient,
AdkPagination,
} from '../directives';
import { ID, Page, Todo } from '../models';

@Directive({
selector: '[adk-todos]',
exportAs: 'adkTodos',
standalone: true,
/**
* IMPORTANT! Angular Directive composition API in action!
*/
hostDirectives: [
{ directive: AdkHttpClient, inputs: ['adkUrl', 'adkPage', 'adkLimit'] },
AdkList,
AdkSelection,
AdkPagination,
],
})
export class AdkTodos<T extends Todo> {
...
}

The hostDirectives is also type-safe. You cannot omit adkUrl since it's a required input.

This directive is as a “container” for using all primitive directives to construct something more domain-specific:

@Directive({
...
})
export class AdkTodos<T extends Todo> {
#httpClient: AdkHttpClient;
#list: AdkList<T>;
#selection: AdkSelection;
#pagination: AdkPagination;

/**
* The list of todos
*/
items: Signal<T[]>;

/**
* Check if we are on the first page
*/
first: Signal<boolean>;

/**
* Check if we are on the last page
*/
last: Signal<boolean>;

/**
* The total number of the selected todos
*/
selectedCount: Signal<number>;

/**
* The total number of todos
*/
total: Signal<number>;

/**
* Fetch the todos from the server
*/
async fetch(): Promise<void> {
...
}

/**
* Go to the next page
*/
async next(): Promise<void> {
...
}

/**
* Go to the previous page
*/
async previous(): Promise<void> {
...
}

/**
* Select todos by their ids
* @param ids
*/
select(...ids: ID[]): void {
...
}

/**
* Select all todos
*/
selectAll(): void {
...
}

/**
* Deselect all todos
*/
reset(): void {
...
}

/**
* Check if a todo is selected
* @param id
*/
selected(id: ID): boolean {
...
}

/**
* Deselect a todo by its id
* @param id
*/
deselect(id: ID): void {
...
}
}

Within this directive, we encapsulate:

  • AdkHttpClient
  • AdkList
  • AdkSelection
  • AdkPagination

These directives work in concert to provide functionality for managing TODOs efficiently.

Let’s explore and test various scenarios. While the HttpTestingController is valuable, let's replace AdkHttpClient with a mocked version to focus more on AdkTodos directive and less on internal HTTP calls.

Please pay attention to the AdkTodos - MOCK_HTTP_CLIENT section, where I substitute AdkHttpClient with a mocked implementation.

describe('AdkTodos', () => {
it('should fetch todos', async () => {
...
});

it('should go to the next page', async () => {
...
});
});

describe('AdkTodos — MOCK_HTTP_CLIENT', () => {
it('should fetch todos', async () => {
...
});

it('should select one item', async () => {
...
});

it('should deselect one item', async () => {
...
});
});

Here’s the test suite:

import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AdkTodos } from './todo';
import { Page, Todo } from '../models';
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { AdkHttpClient } from '../directives';

const TODO_DATA_MOCK: Todo[] = Array(20)
.fill(null)
.map((_, index) => ({
id: index + 1,
title: `Todo ${index + 1}`,
completed: false,
}));

const HTTP_CLIENT_MOCK = {
async get(page?: Page): Promise<{ total: number; items: Todo[] }> {
return {
total: TODO_DATA_MOCK.length,
items: TODO_DATA_MOCK.slice(0, 10),
};
},
};

@Component({
standalone: true,
selector: 'adk-host',
template: ``,
hostDirectives: [AdkTodos],
})
class HostComponent {}

describe('AdkTodos', () => {
let todos: AdkTodos<Todo>;

beforeEach(async () => {
const module = TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
imports: [HostComponent],
});
await module.compileComponents();
const fixture = module.createComponent(HostComponent);

todos = fixture.debugElement.injector.get(AdkTodos);
fixture.componentRef.setInput(
'adkUrl',
'https://jsonplaceholder.typicode.com/todos'
);
});

it('should fetch todos', async () => {
const promise = todos.fetch();

const controller = TestBed.inject(HttpTestingController);
controller
.expectOne(
'https://jsonplaceholder.typicode.com/todos?_page=1&_per_page=10'
)
.flush([{ id: 1, title: 'Todo 1', completed: false }], {
headers: { 'X-Total-Count': '1' },
});

await promise;
expect(todos.items()).toEqual([
{ id: 1, title: 'Todo 1', completed: false },
]);
expect(todos.total()).toBe(1);
controller.verify();
});

it('should go to the next page', async () => {
const promise = todos.fetch();

const controller = TestBed.inject(HttpTestingController);

controller
.expectOne(
'https://jsonplaceholder.typicode.com/todos?_page=1&_per_page=10'
)
.flush(TODO_DATA_MOCK.slice(0, 10), {
headers: { 'X-Total-Count': '20' },
});

await promise;
expect(todos.total()).toEqual(20);

todos.next();

controller
.expectOne(
'https://jsonplaceholder.typicode.com/todos?_page=2&_per_page=10'
)
.flush(TODO_DATA_MOCK.slice(10), {
headers: { 'X-Total-Count': '20' },
});

controller.verify();
});
});

describe('AdkTodos — MOCK_HTTP_CLIENT', () => {
let todos: AdkTodos<Todo>;

beforeEach(async () => {
const module = TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
imports: [HostComponent],
});
/**
* IMPORTANT! How we can override the directive with a mock implementation
*/
module.overrideDirective(AdkHttpClient, {
set: {
providers: [{ provide: AdkHttpClient, useValue: HTTP_CLIENT_MOCK }],
},
});
await module.compileComponents();

const fixture = module.createComponent(HostComponent);

todos = fixture.debugElement.injector.get(AdkTodos);
fixture.componentRef.setInput(
'adkUrl',
'https://jsonplaceholder.typicode.com/todos'
);
});

it('should fetch todos', async () => {
await todos.fetch();
expect(todos.items()).toEqual(TODO_DATA_MOCK.slice(0, 10));
expect(todos.total()).toBe(20);
});

it('should select one item', async () => {
await todos.fetch();
const id = todos.items()[0].id;
todos.select(id);

expect(todos.selected(id)).toBe(true);
expect(todos.selectedCount()).toBe(1);
});

it('should deselect one item', async () => {
await todos.fetch();
const id = todos.items()[0].id;
todos.select(id);
todos.deselect(id);

expect(todos.selected(id)).toBe(false);
expect(todos.selectedCount()).toBe(0);
});
});

And here’s the final implementation of this directive:

import { Directive, inject } from '@angular/core';
import {
AdkList,
AdkSelection,
AdkHttpClient,
AdkPagination,
} from '../directives';
import { ID, Page, Todo } from '../models';

@Directive({
selector: '[adk-todos]',
exportAs: 'adkTodos',
standalone: true,
/**
* Important! Angular Directive composition API in action!
*/
hostDirectives: [
{ directive: AdkHttpClient, inputs: ['adkUrl', 'adkPage', 'adkLimit'] },
AdkList,
AdkSelection,
AdkPagination,
],
})
export class AdkTodos<T extends Todo> {
#httpClient = inject(AdkHttpClient, { self: true }); // { self: true } to optimizate the search in ID
#list = inject<AdkList<T>>(AdkList, { self: true });
#selection = inject(AdkSelection, { self: true });
#pagination = inject(AdkPagination, { self: true });

/**
* The list of todos
*/
readonly items = this.#list.items;

/**
* Check if we are on the first page
*/
readonly first = this.#pagination.first;

/**
* Check if we are on the last page
*/
readonly last = this.#pagination.last;

/**
* The total number of the selected todos
*/
readonly selectedCount = this.#selection.count;

/**
* The total number of todos
*/
readonly total = this.#pagination.total.asReadonly();

/**
* Fetch the todos from the server
*/
async fetch(): Promise<void> {
const page: Page = {
page: this.#pagination.page(),
limit: this.#pagination.limit(),
};
const { total, items } = await this.#httpClient.get<T>(page);
this.#pagination.total.set(total);
this.#list.add(...items);
}

/**
* Go to the next page
*/
async next(): Promise<void> {
this.#pagination.next();
this.#list.clear();
await this.fetch();
}

/**
* Go to the previous page
*/
async previous(): Promise<void> {
this.#pagination.previous();
this.#list.clear();
await this.fetch();
}

/**
* Select todos by their ids
* @param ids
*/
select(...ids: ID[]): void {
this.#selection.select(...ids);
}

/**
* Select all todos
*/
selectAll(): void {
this.#selection.select(...this.items().map((todo) => todo.id));
}

/**
* Deselect all todos
*/
reset(): void {
this.#selection.clear();
}

/**
* Check if a todo is selected
* @param id
*/
selected(id: ID): boolean {
return this.#selection.selected(id);
}

/**
* Deselect a todo by its id
* @param id
*/
deselect(id: ID): void {
this.#selection.deselect(id);
}
}

Angular Directive composition API in action

In the following example, Angular Directive composition API is demonstrated, encapsulating details within the AdkTodos directive:

import { Component, OnInit, inject } from '@angular/core';
import { AdkTodos } from './todo';

@Component({
standalone: true,
selector: 'adk-todos',
hostDirectives: [AdkTodos], // IMPORTANT! Angular Directive composition API in action!
...
],
})
export class TodosComponent implements OnInit {
todos = inject(AdkTodos, { self: true }); // Nice!

ngOnInit(): void {
this.todos.fetch();
}
}

The HTML template utilizes the AdkTodos directive to interact with TODOs items:

import { Component, OnInit, inject } from '@angular/core';
import { AdkTodos } from './todo';

@Component({
standalone: true,
selector: 'adk-todos',
hostDirectives: [AdkTodos],
template: `
<header>
<button (click)="todos.reset()">Reset All</button>
</header>
<table>
<thead>
<tr>
<th>Id</th>
<th>Title</th>
<th>Completed</th>
<th>Selection ({{ todos.selectedCount() }})</th>
</tr>
</thead>
<tbody>
@for (item of todos.items(); track (item.id)) {
<tr>
<td>{{ item.id }}</td>
<td>{{ item.title }}</td>
<td>{{ item.completed }}</td>
<td>
<button
(click)="
todos.selected(item.id)
? todos.deselect(item.id)
: todos.select(item.id)
"
>
{{ todos.selected(item.id) ? 'Deselect' : 'Select' }}
</button>
</td>
</tr>
}
</tbody>
</table>

<footer>
<button [disabled]="todos.first()" (click)="todos.previous()">
Previous
</button>
&nbsp;
<button [disabled]="todos.last()" (click)="todos.next()">Next</button>
</footer>
`
})
export class TodosComponent implements OnInit {
todos = inject(AdkTodos, { self: true });

ngOnInit(): void {
this.todos.fetch();
}
}

The usage of AdkTodos outside of TodosComponent is shown in the AppComponent:

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { TodosComponent } from './components';

const components = [TodosComponent];

@Component({
selector: 'adk-root',
standalone: true,
template: `
<adk-todos
adkUrl="https://jsonplaceholder.typicode.com/todos" // A required input
[adkPage]="1" // An optional input
[adkLimit]="10" // An optional input
></adk-todos>
`,
imports: [CommonModule, RouterOutlet, ...components],
})
export class AppComponent {}

Testing

To ensure everything is functioning correctly, it’s essential to run tests and verify that they all pass:

Type-safe Inputs

The inputs for directives are type-safe, which enhances the robustness of our code. Additionally, we can specify inputs as required to enforce their presence. In this context, adkPage and adkLimit are optional fields, allowing us to omit them if necessary.

Angular DevTools

Integration with Angular DevTools Chrome extension is seamless and enhances the debugging and development experience, providing valuable insights into the application’s structure and behavior.

The provided code demonstrates the power of Angular’s directive composition API, allowing encapsulation of details within directives and enabling clean usage Angular Signals in view templates.

It enhances modularity and extensibility, promoting clean separation of concerns and enhancing code organization.

By following these practices, we can ensure the reliability, maintainability, and efficiency of our Angular application.

For hands-on experimentation, you can access the Stackblitz.

Thank you! đź–¤

--

--