Healthy communication and maintaining strong binding are key in any relationship. Same goes in any Angular as well. In this article, we will explore the ways Angular offers to form bindings between templates and code-behind, along with communication between components.
Writing articles with bare hands is a tough job, but worth if it helps others. Keeping that in mind, let us target the goal of this article. Angular is completely component driven with the idea of dividing UI elements, pages and screens into smaller components and dealing with them individually, respecting SRP. Some primary Angular concepts are briefed below, which we will explore practically with our Notesy app continued from previous article:
NOTE: We will showcase only the snippets of code in this article to keep it short and crips. If you want to view the entire code base feel free to clone the repository and view the entire file, the links are given at the bottom of the article.
We will now talk in the context of Notesy App and understand the above-mentioned concepts, lets proceed with below pointers:
If we recall where we left in the previous article, we had a list component which looks like this, list.component.html:
<app-item></app-item>
<app-item></app-item>
<app-item></app-item>
Pretty ugly, but let's add life to this. Instead of manually repeating the component, we will iterate through Angular control flow called @for, list.component.html:
@for (note of [1, 2, 3]) {
<app-item></app-item>
}
Here, what we did is simply wrapped the item component within a @for control flow. The syntax looks like:
@for (variable_name of array_name) {...}
Here, the variable name can be anything, and the array can be defined in the TypeScript file. In previous code we just created an array on fly with 3 numbers, which makes the loop iterates for 3 times.
We will create a notes data file from which we will iterate actual notes, for this will add a data file in the app folder named as data.ts file, along with a note.model.ts file:
export interface Note {
id: string;
content: string;
}
The data.ts file:
import { Note } from "./models/note.model";
export const notes: Note[] = [
{
id: '67b15a30-df51-4e0f-8cc3-b061bdcc12ee',
content: 'Consectetur labore est amet aute pariatur enim in sint pariatur ipsum culpa.',
},
{
id: '4496fcdc-e3fe-46e1-9c58-e8f83ef1f587',
content: 'Et occaecat amet duis anim.',
},
{
id: 'cc2826d0-6939-42a1-a8aa-3639cb38eb5a',
content: 'Voluptate voluptate cupidatat ut eiusmod Lorem consequat commodo.',
},
];
Let's consume this array in our list component .ts:
import { notes as notesData } from '../data';
@Component({...});
export class ListComponent {
notes = notesData;
}
Updating the list component .html:
@for (note of notes) {
<app-item></app-item>
}
This makes the notes card repeats 3 times.
But what if we don't have any notes list to show, in that case we must show the empty component. If the notes length is zero then we show the message, otherwise won't:
@if (notes.length === 0) {
<app-empty></app-empty>
}
Here, we have added the @if control flow, which help us to write such conditional logics. Finally, the list.component.html will look this:
@for (note of notes) {
<app-item [note]="note" (onRemove)="onRemove($event)"></app-item>
}
@if (notes.length === 0) {
<app-empty></app-empty>
}
The item component is not showing the correct data so far. Let's make it dynamic to have the supplied note object from the list component. Here, we are now talking about communication between a parent component (list) and a child component (item). To supply values to the child component we have to create a property in the child component with a decorator (@Input), item.component.html:
import { Component, Input } from '@angular/core';
import { Note } from '../../models/note.model';
@Component({...})
export class ItemComponent {
@Input() note: Note;
}
Here, we have created a note property of type Note with @Input decorator. It's time to supply the note input to item component from parent component, list.component.html:
@for (note of notes) {
<app-item [note]="note"></app-item>
}
Here, we are doing a property binding using the square brackets with the same name as input present in the item component.
Additionally, we can make the input parameter required which as well provide error on consumption of the child component anywhere in any future parent component. To do so we update the @Input decorator in the item.component.ts:
import { Component, Input } from '@angular/core';
import { Note } from '../../models/note.model';
@Component({...})
export class ItemComponent {
@Input({ required: true }) note!: Note;
}
Here, we have applied a not operator on note property stating it's not nullable.
This makes the note value to reach to the item component, but it is still not in consumption. Will update the item.component.html file:
<div class="card bg-body-secondary border-0 mb-2">
<div class="card-body">
<p class="card-text">
<span class="d-flex justify-content-between align-items-center">
<span>{{note.content}}</span>
<button type="button" class="btn btn-light ms-3" title="Remove">
<i class="bi bi-trash-fill text-danger fs-6"></i>
</button>
</span>
</p>
</div>
</div>
Here, in order to consume the property value in the .html template file which is called as one way binding (interpolation) we use the 2 curly braces syntax ({{property}}).
The property we have already seen previously in the list.component.html file:
<app-item [note]="note"></app-item>
Coming back to the data.ts file, which is currently a static data source and won't be there in near future of this article series. To come closer to real world scenario, let us create a service to share data between components those are beyond parent-child relationship. To do so in Angular we have concept called services. Services are injectable classes those are globally accessible and has multiple usage scenarios in Angular. For now, narrowing down the usage of service as a sharable unit between components. We will see more usage of services in this Angular journey series. To create a service, we run below command in a new "services" folder:
ng g s note
We get a service shown below, note.service.ts:
import { Injectable } from '@angular/core';
import { notes as notesData } from '../data';
@Injectable({
providedIn: 'root'
})
export class NoteService {
notes = notesData;
}
Here, we have added the notes data from the data.ts file. Let us now consume this in the list.component.ts file:
import { Component, inject } from '@angular/core';
import { Note } from '../../models/note.model';
import { NoteService } from '../../services/note.service';
@Component({...})
export class ListComponent {
noteService = inject(NoteService);
get notes() {
return this.noteService.notes;
}
set notes(value: Note[]) {
this.noteService.notes = value;
}
}
Here, we have injected the NoteService using the inject method of the list component. We also created a getter and setter for the same for future usage.
This brings our attention to the form component now, let us add the note service to the form component as well, as the usage will be there in the form.component.ts file:
import { Component, inject } from '@angular/core';
import { NoteService } from '../../services/note.service';
@Component({...})
export class FormComponent {
noteService = inject(NoteService);
}
In the form component we are now adding a click event to the add button, the form.component.html looks like:
<form>
<div class="input-group mb-4">
<input type="text" class="form-control" placeholder="Write some notes..." />
<button class="btn btn-success" type="submit" title="add" (click)="onAdd($event)">
<i class="bi bi-plus-lg fs-5 fw-bolder"></i>
</button>
</div>
</form>
Adding necessary "onAdd" method in the form.component.ts file:
import { Component, inject } from '@angular/core';
import { NoteService } from '../../services/note.service';
import { Note } from '../../models/note.model';
@Component({...})
export class FormComponent {
noteService = inject(NoteService);
onAdd(event: Event) {
const newNote: Note = {
id: 1000,
content: 'New Note',
};
this.noteService.notes = [...this.noteService.notes, newNote];
}
}
Here, we are adding the new note to the notes service property.
This is cool but not enough, let's do something about the id and the content. For the id we will use uuid NPM package to get always new id. Run below command for uuid:
npm i uuid
Using the uuid for the new note as below:
import { Component } from '@angular/core';
import { NoteService } from '../../services/note.service';
import { Note } from '../../models/note.model';
import { v4 as uuid } from 'uuid';
@Component({...})
export class FormComponent {
noteService = inject(NoteService);
onAdd(event: Event) {
const newNote: Note = {
id: uuid(),
content: 'New Note',
};
this.noteService.notes = [...this.noteService.notes, newNote];
}
}
To get the actual note content entered from the input text we will use the two-way binding syntax of Angular using ngModel directive, in the form.component.html:
<form>
<div class="input-group mb-4">
<input type="text" class="form-control" placeholder="Write some notes..." [(ngModel)]="content" name="content" />
<button class="btn btn-success" type="submit" title="add" (click)="onAdd($event)">
<i class="bi bi-plus-lg fs-5 fw-bolder"></i>
</button>
</div>
</form>
But we need a property named content as well and also, since the ngModel comes from "FormsModule", we need to include in the imports array of form.component.ts:
import { Component, inject } from '@angular/core';
import { NoteService } from '../../services/note.service';
import { Note } from '../../models/note.model';
import { v4 as uuid } from 'uuid';
@Component({
selector: 'app-form',
imports: [FormsModule],
templateUrl: './form.component.html',
styleUrl: './form.component.scss',
})
export class FormComponent {
content: string = '';
noteService = inject(NoteService);
onAdd(event: Event) {
const newNote: Note = {
id: uuid(),
content: this.content,
};
this.noteService.notes = [...this.noteService.notes, newNote];
}
}
Last but not least, let us work on the removal of the notes by clicking on the remove button of the item.component.html:
<div class="card bg-body-secondary border-0 mb-2">
<div class="card-body">
<p class="card-text">
<span class="d-flex justify-content-between align-items-center">
<span>{{note.content}}</span>
<button type="button" class="btn btn-light ms-3" (click)="remove()" title="Remove">
<i class="bi bi-trash-fill text-danger fs-6"></i>
</button>
</span>
</p>
</div>
</div>
Here, we have added the remove method and bind it to the click event. Creating the remove method in the form.component.ts file:
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Note } from '../../models/note.model';
@Component({...})
export class ItemComponent {
@Input({ required: true }) note!: Note;
@Output() onRemove = new EventEmitter<Note>();
remove() {
this.onRemove.emit(this.note);
}
}
Here, we have added the @Output decorator for the onRemove event emitter. When child component wants to inform parent regarding event occurrence then we EventEmitter class which further can be consumed in the list.component.html:
@for (note of notes) {
<app-item [note]="note" (onRemove)="onRemove($event)"></app-item>
}
In the list.component.ts file:
import { Component, inject } from '@angular/core';
import { Note } from '../../models/note.model';
import { NoteService } from '../../services/note.service';
@Component({...})
export class ListComponent {
onRemove(note: Note) {
this.notes = this.notes.filter((n) => n.id !== note.id);
}
}
Here, the onRemove method of list component will be triggered from the child component remove event.
Let us add the basic validation logic in the onAdd method, form.component.ts:
onAdd(event: Event) {
event.preventDefault();
if (!this.content) {
alert('Please fill the form');
return;
}
const newNote: Note = {
id: uuid(),
content: this.content,
};
this.noteService.notes = [...this.noteService.notes, newNote];
}
We have also added the event.preventDefault() as we are inside the form element and to avoid the post back this is required, rest remains the basic validation logic.
Adding now the try-catch to be resilient and error free coding:
onAdd(event: Event) {
try {
event.preventDefault();
if (!this.content) {
alert('Please fill the form');
return;
}
const newNote: Note = {
id: uuid(),
content: this.content,
};
this.noteService.notes = [...this.noteService.notes, newNote];
} catch (error: any) {
alert(`Error Occurred: ${error.message}`);
} finally {
this.content = '';
}
}
Lastly, to show new notes appear on top we need to use a pipe.
To show the last added item in the notes array on top as a first item on the UI, we simply needed to reverse the array while displaying. To do so, we will create a pipe which transform the notes array in reverse order on the template itself, thus transforming data on fly. Create a pipe using the Angular CLI command "ng g p reverse":
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'reverse',
})
export class ReversePipe implements PipeTransform {
transform<T>(value: T[]): T[] {
return value ? [...value].reverse() : [];
}
}
Here, in the transform method we are accepting a value (which is generic array) and doing a reverse. Also, in the decorator the name of the pipe is mentioned as "reverse", consume this pipe in the list.component.html:
@for (note of notes | reverse) {
<app-item [note]="note" (onRemove)="onRemove($event)"></app-item>
}
@if (notes.length === 0) {
<app-empty></app-empty>
}
Here, we have added a "|" pipe operator followed by the name of the pipe which is "reverse" in our case.
So far, we are having fewer notes in our Notesy app but won't be like that once you start adding more and more notes. The DOM manipulation will take times whenever there will be change in the state of notes array, in order to minimize this, we can identify the unique property in our data present with each note, and use a track expression with @for, this will significantly improve performance. Read more from here - Why is track in @for blocks important?
We can add the track expression in the @for control flow as shown:
@for (note of notes | reverse; track note.id) {
<app-item [note]="note" (onRemove)="onRemove($event)"></app-item>
}
@if (notes.length === 0) {
<app-empty></app-empty>
}
Here, we have added the track expression just after the pipe, and we are using the "note.id" to uniquely track the notes array changes.
December 31, 2020
October 19, 2020
March 02, 2022