Data bindings (input e output) in Angular | Aulab
Riserva il tuo posto per settembre entro il 17/08
Sconti fino a 1.200€ sui corsi Digital & Tech! 👀 Iscriviti ora

GUIDE PER ASPIRANTI PROGRAMMATORI

Data bindings (input e output) in Angular

Generalmente, la ragione per cui un componente dichiara altri componenti nel suo template può essere riassunta nel concetto di delega: allo stesso modo in cui una funzione chiama altre funzioni per delegare operazioni di più basso livello, lo stesso avviene nella UI. Il meccanismo della delega e quello della composizione vanno a braccetto in programmazione…

Lezione 20 / 26
Enza Neri
Immagine di copertina

Vuoi avviare una nuova carriera o fare un upgrade?

Trova il corso Digital & Tech più adatto a te nel nostro catalogo!

Generalmente, la ragione per cui un componente dichiara altri componenti nel suo template può essere riassunta nel concetto di delega: allo stesso modo in cui una funzione chiama altre funzioni per delegare operazioni di più basso livello, lo stesso avviene nella UI. Il meccanismo della delega e quello della composizione vanno a braccetto in programmazione informatica e ci consentono di gestire la complessità di un progetto riusando componenti oppure separandoli per sfere di competenza.-

Parlando di input e output properties abbiamo coperto l’API di comunicazione tra componenti padri e figli nel template; in questa sezione andremo a vedere come strutturare al meglio questa comunicazione prendendo ad esempio una classe di casi molto comuni.

Rivediamo brevemente la template syntax per quanto riguarda data e event binding:

<child-component [inputProperty]="data" (outputProperty)="action()" />

L’uso tipico di una input property consiste nel passare dati a un componente dedicato alla loro presentazione; quello di una output property nell’agganciare della logica ad un’azione dell’utente. In questo modo rendiamo delegabili le due funzioni di una UI: presentazione e interazione.

Un paradigma comune in Angular (ma anche in altri framework) prevede la distinzione dei componenti tra due categorie:

  • da un lato, i componenti che si occupano di gestire la logica dello stato e la reazione agli eventi;
  • dall’altro, i componenti che si focalizzano sulla rappresentazione dei dati e sul notificare l’interazione dell’utente.

Questa dicotomia passa sotto molti nomi diversi:

  • una nomenclatura che va per la maggiore chiama i primi smart component (componenti intelligenti) e i secondi dumb component (componenti stupidi); questa nomenclatura non è particolarmente riconoscente nei confronti dei componenti che si occupano dell’aspetto grafico ed estetico e della corretta esposizione degli eventi che l’utente scatena;
  • un’altra nomenclatura, che possiamo prendere in prestito da architetture più complesse, vede una distinzione tra controller e view, rendendo maggiore giustizia alle diverse responsabilità.

In generale, non è necessario né desiderabile avere una segregazione categorica dei componenti a prescindere; dopotutto, se li usiamo bene, la component class e i template sono a tutti gli effetti divisi tra controller e view, perciò questo design pattern è già implementato dentro Angular.

Qui si vuole esemplificare un modo per affrontare quei casi in cui la complessità di un componente rende necessaria la sua suddivisione; allora e solo allora ha senso spezzare il componente a sua volta in due secondo un criterio analogo a quello adottato da Angular per organizzare internamente ogni singolo componente.

Prendiamo ad esempio una situazione in cui un componente debba:

  • scaricare dei dati dal server;
  • mostrarli all’utente fornendo la possibilità di modificarli;
  • consentire all’utente di salvare i dati modificati inviandoli al server;
  • consentire all’utente di annullare le modifiche.

Il tutto sempre indicando correttamente lo stato dei caricamenti e mostrando con template diversi i dati in sola lettura e quelli in modifica.

Nulla ci vieterebbe di implementare tutto questo in un solo componente; il problema è che avremmo sostanzialmente due fronti di interazione (da un lato il backend, dall’altro la UI) che determinano due stati parzialmente interdipendenti. Sarebbe sufficiente un minimo incremento di complessità (per esempio la necessità di riusare il form in un altro punto dell’applicazione) per richiedere la ripetizione di molto codice oppure una modifica sostanziale dell’architettura del nostro componente.

Dunque, possiamo semplificarci un po’ la vita pensando ai due aspetti separatamente: da un lato il recupero e l’invio dei dati, con le rispettive logiche di caricamento, ricarica, eccetera; dall’altro presentazione, modifica, validazione e infine conferma dei dati da parte dell’utente.

Cominciamo col definire una semplicissima entità di lavoro, che farà da data model al nostro esempio, e la mettiamo in un file a sé stante, ad esempio entity.ts:

export type Entity = {
  id: string;
  label: string;
};

Qualunque situazione reale vedrebbe molte più proprietà coinvolte, ma noi possiamo accontentarci di questo.

A questo punto ipotizziamo di aver già recuperato i dati e pensiamo esclusivamente a come vogliamo rappresentarli e consentirne la modifica; nel farlo, costruiremo un’API per delegare gli aspetti che non vogliamo considerare all’interno del nostro DumbComponent.

In dumb.component.ts:

import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { JsonPipe } from '@angular/common';
import { Entity } from '../domain';


type EntityForm = {
  id: FormControl<string | null>;
  label: FormControl<string | null>;
};


type State =
  | { mode: 'view' }
  | {
    mode: 'edit',
    init: Entity,
    form: FormGroup<EntityForm>
  };


@Component({
  selector: 'app-dumb',
  standalone: true,
  imports: [ReactiveFormsModule, JsonPipe],
  templateUrl: './dumb.component.html',
  styleUrl: './dumb.component.css'
})
export class DumbComponent {
  state: State = { mode: 'view' };


  @Input({ required: true }) entity!: Entity;
  @Output() entityChange = new EventEmitter<Entity>();
  @Output() save = new EventEmitter<Entity>();


  edit(): void {
    this.state = {
      mode: 'edit',
      init: this.entity,
      form: new FormGroup({
        id: new FormControl(this.entity.id),
        label: new FormControl(this.entity.label, [Validators.required])
      })
    };
  }


  raiseUpdate(form: FormGroup<EntityForm>): void {
    this.entityChange.next(form.value as Entity);
  }


  raiseSave(form: FormGroup<EntityForm>): void {
    this.save.next(form.value as Entity);
  }


  cancelForm(entity: Entity): void {
    this.entityChange.next(entity);
    this.state = { mode: 'view' };
  }
}

Qui succedono un sacco di cose! Vediamole nel dettaglio.


Per cominciare, dichiariamo il model del form che useremo per consentire la modifica; dichiariamo anche lo stato, che ha 2 casi, uno per la lettura e uno per la modifica dei dati. Il caso edit porta anche un riferimento all’oggetto iniziale usato per costruire il form, e il form stesso, dotato di tutti i validatori e le logiche interne necessarie.

In una situazione concreta emergerebbe tutta la complessità che certi form comportano e, dunque, la necessità di delegare le logiche di interazione con l’app (es. routing) e con il back end (http).

Le input e output property costituiscono l’API per il componente padre; il nostro componente accetta l’entità da mostrare in input ed espone in output due eventi, che in Angular sono implementati dalla classe EventEmitter.
Mentre il funzionamento delle input property risulta sostanzialmente chiaro a prima vista, l’uso di EventEmitter è strettamente legato a RxJS ed è fondato sull’uso del metodo next per chiamare la funzione che è in ascolto sull’evento dal template del componente padre. Internamente, EventEmitter funziona come un Observable e la sintassi di event binding indica ad Angular di chiamare subscribe su quell’Observable, ma anche di chiudere la subscription quando il componente non è più attivo nel template, evitando memory leak.

Riguardo all’evento entityChange, qui lo abbiamo implementato per abilitare il two-way binding sulla proprietà entity, come vedremo a breve; questo crea una parziale sovrapposizione tra l’uso di entityChange e quello di save, poiché entrambi gli eventi forniscono il valore aggiornato di entity, ma secondo criteri diversi:

  • nel caso di entityChange abbiamo un semplice tracker in tempo reale delle modifiche che l’utente apporta al form; questo permetterebbe al componente padre di implementare logiche custom indipendenti dalla validazione e dalla sottoscrizione del form;
  • nel caso di save abbiamo il model in uscita dal form, validato e sottoscritto da un’azione esplicita dell’utente.

All’atto pratico, in questo esempio entityChange assolve a uno scopo puramente dimostrativo.

Un’ultima nota sull’evento save: potremmo essere tentati di dare ai nostri eventi nomi semplici e comuni come click o submit, ma questa pratica è tendenzialmente da evitare. Il funzionamento degli eventi in Angular potrebbe portare a dei conflitti di nomi tra i nostri eventi e quelli standard degli elementi HTML. Questo è anche il motivo per cui usiamo ngSubmit invece di submit, come avevamo anticipato parlando dei forms.

Passando al template, il nostro dumb.component.html avrà questo aspetto:

@switch (state.mode) {
  @case ('view') {
    <pre>{{entity | json}}</pre>
    <button type="button" (click)="edit()">Edit</button>
  }
  @case ('edit') {
    <form
      [formGroup]="state.form"
      (input)="raiseUpdate(state.form)"
      (ngSubmit)="raiseSave(state.form)"
    >
      <input
        type="hidden"
        formControlName="id"
      >
      <input
        type="text"
        formControlName="label"
        placeholder="Insert label"
      >
      <div class="actions"> 
        <button
          type="button"
          (click)="cancelForm(state.init)"
        >
          Cancel
        </button>
        <button
          type="submit"
          [disabled]="state.form.invalid"
        >
          Save
        </button>
      </div>
    </form>
  }
}

Anche qui ci sono un po’ di cose da spiegare.

Tanto per cominciare, abbiamo il canonico switch su state.mode, grazie al quale possiamo differenziare la presentazione della nostra entità ma anche e soprattutto distinguere tra i due diversi tipi di stato.

Questo ci porta alla seconda osservazione: i metodi chiamati nel caso edit non lavorano direttamente con l’istanza this.state del componente, ma si fanno passare state.form e state.init dal template in modo tale da non dover distinguere due volte tra i casi di state.mode e, dunque, lavorare in modo perfettamente type safe.

Una nota sulla nomenclatura: abbiamo adottato la convenzione smart/dumb esclusivamente per la semplicità dei termini; in contesti reali, il giusto nome per un componente è sempre quello più descrittivo delle sue responsabilità e non quello del ruolo che riveste nell’architettura; le architetture presentano convenzioni utili per generalizzare i problemi, ma il nostro codice operativo dovrebbe sempre rimanere ben ancorato al dominio della nostra applicazione.

A questo punto, siamo pronti per vedere una possibile implementazione del componente padre, e incominciamo questa volta dal template, così da illustrare immediatamente come vengono effettuati i binding.

In smart.component.html troviamo:

@switch (state.type) {
  @case ('loading') {
    <p>Loading...</p>
  }
  @case ('success') {
    <div>
      <p>Form</p>
      <app-dumb [(entity)]="state.entity" (save)="save($event)" />
    </div>
    <div>
      <p>Entity</p>
      <pre>{{ state.entity | json }}</pre>
      <button type="button" (click)="ngOnInit()">Refresh</button>
    </div>
  }
}

Da qui possiamo immediatamente dedurre che il nostro smart component gestirà due casi: un caricamento e un successo. Nel caso di successo vediamo app-dumb con un binding a doppia via sulla proprietà entity; questo include implicitamente un event binding del tipo: (entityChange)=”entity = $event” che potremo riscontrare nel tag pre poco sotto. Vediamo anche il nostro evento save legato a un metodo onSave sul componente.

Prima di proseguire, parliamo un attimo della variabile $event, che abbiamo già incontrato in altri esempi in passato: questa corrisponde esattamente al parametro passato al metodo EventEmitter.next e il suo nome è fisso e convenzionalmente assegnato da Angular.

Ora possiamo passare a smart.component.ts:

import { Component, OnInit } from '@angular/core';
import { DumbComponent } from '../dumb/dumb.component';
import { Entity } from '../domain';
import { map, Observable, timer } from 'rxjs';
import { JsonPipe } from '@angular/common';


type State =
  | { type: 'loading' }
  | { type: 'success'; entity: Entity };


const mockGet = (): Observable<Entity> => (
  timer(1000).pipe(map(() => ({ id: 'mock_id', label: 'Some label' })))
);

const mockPost = (entity: Entity): Observable<Entity> => (
  timer(1000).pipe(map(() => entity))
);

@Component({
  selector: 'app-smart',
  standalone: true,
  imports: [DumbComponent, JsonPipe],
  templateUrl: './smart.component.html',
  styleUrl: './smart.component.css'
})
export class SmartComponent implements OnInit {
  state!: State;

  ngOnInit(): void {
    this.state = { type: 'loading' };

    mockGet()
      .subscribe(entity => this.update(entity));
  }

  save(entity: Entity): void {
    this.state = { type: 'loading' };

    mockPost(entity)
      .subscribe(entity => this.update(entity));
  }

  update(entity: Entity): void {
    this.state = { type: 'success', entity };
  }
}

Qui troviamo relativamente tutte cose già viste: uno stato rappresentato come tagged union, con i due casi che ci aspettavamo dal template, e i metodi che si occupano di effettuare operazioni verso un back end “fantoccio” (per esemplificare) che risponde dopo 1 secondo.

Hai bisogno di informazioni? 🙋🏻‍♂️

Parliamone! Scrivici su Whatsapp e risponderemo a tutte le tue domande per capire quale dei nostri corsi è il più adatto alle tue esigenze.

Oppure chiamaci al 800 128 626