Angular Signal Input - The Solution to Angular Inputs
Angular Signal Input - The Solution to Angular Inputs

In this post, you'll learn about Angular Signal Input and how to use it within Angular 17.

Not too long ago, Angular 17.1 introduced a cool new feature called Angular Signal Input.

github

As indicated in the Angular 17.1 release notes, the core now exposes a new API for signal-based inputs.

Now, I've prepared a pagination component for us to explore.

@Component({
  selector: 'pagination',
  standalone: true,
  imports: [RouterOutlet, CommonModule],
  template: `
    <ul class="pagination">
      <li
        *ngFor="let page of pages"
        [ngClass]="{ 'page-item': true, active: currentPage === page }"
        (click)="changePage.emit(page)"
      >
        <span class="page-link">{{ page }}</span>
      </li>
    </ul>
  `,
})
export class PaginationComponent implements OnInit {
  @Input() currentPage: number = 1;
  @Input() total: number = 0;
  @Input() limit: number = 20;
  @Output() changePage = new EventEmitter<number>();

  pages: number[] = [];

  ngOnInit(): void {
    const pagesCount = Math.ceil(this.total / this.limit);
    this.pages =  this.range(1, pagesCount);
  }

  range(start: number, end: number): number[] {
    return [...Array(end - start).keys()].map((el) => el + start);
  }
}

This component renders a list of pages and has several inputs: currentPage, total, and limit. Inside ngOnInit, we calculate the pages array and render it on the screen.

What are the issues with inputs in Angular?

no initializer

When a default value isn't provided, an error occurs indicating that no default value was provided. This error can be frustrating because it can only be resolved by providing a default value. However, there are instances where providing a default value isn't desirable. Instead, you may prefer to indicate that inputs are required without default values.

@Input({required: true}) currentPage: number

Even if we specify that currentPage is required, it doesn't resolve the issue with TypeScript. We'll still encounter the same error. To address this, we often disable the error by informing TypeScript that we're confident a value will be provided.

@Input({required: true}) currentPage!: number

However, this approach isn't considered the best practice. Additionally, we lack an efficient method to handle changes to our input. We're left with either creating a setter or using the ngOnChanges function, both of which are imperative and verbose.

ngOnChanges(simpleChanges: SimpleChanges) {
  // apply your logic with current page here
}

Both of these issues are effectively addressed with signals. As you're already familiar with signals, you know that we can create them, render them inside templates, and they work perfectly. However, in the past, we couldn't use signals for inputs. But now, we can.

export class PaginationComponent {
  currentPage = input.required<number>();
  total = input.required<number>();
  limit = input.required<number>();
  ...
}

This code functions exactly like the Input decorator used previously. By using the keyword input, we can also add required if necessary. This code is fully compatible with TypeScript, eliminating any issues with setting default values. The result is an InputSignal.

Most importantly, these properties are only local and not truly separated like inputs.

Even more importantly, they are readable signals and cannot be updated.

But now, we need to adjust our component slightly by adding round brackets to these inputs.

@Component({
  selector: 'pagination',
  standalone: true,
  imports: [RouterOutlet, CommonModule],
  template: `
    <ul class="pagination">
      <li
        *ngFor="let page of pages"
        [ngClass]="{ 'page-item': true, active: currentPage() === page }"
        (click)="changePage.emit(page)"
      >
        <span class="page-link">{{ page }}</span>
      </li>
    </ul>
  `,
})
export class PaginationComponent implements OnInit {
  currentPage = input.required<number>();
  total = input.required<number>();
  limit = input.required<number>();
  @Output() changePage = new EventEmitter<number>();

  pages: number[] = [];

  ngOnInit(): void {
    const pagesCount = Math.ceil(this.total() / this.limit());
    this.pages =  this.range(1, pagesCount);
  }

  range(start: number, end: number): number[] {
    return [...Array(end - start).keys()].map((el) => el + start);
  }
}

We simply add round brackets to read all our signals, making the transition from inputs to signals extremely easy. But we can take it a step further. As you can see, we have logic inside ngOnInit. We can move it to a computed signal, making the pagination list update automatically whenever an input changes.

export class PaginationComponent {
  ...
  pages = computed(() => {
    const pagesCount = Math.ceil(this.total() / this.limit());
    return this.range(1, pagesCount);
  });
  ...
}

Here, we've moved our pages to a computed signal, which is based on our input signals. This makes our code declarative and ensures that the pages array is updated whenever we change our inputs.

*ngFor="let page of pages()"

But we should not forget to add round brackets to our array of pages. This code is much easier to read and maintain.

Additionally, if you're looking to enhance your Angular knowledge and prepare for interviews, I highly recommend checking out my course Angular Interview Questions - Coding Interview 2023.

📚 Source code of what we've done