Un template nu e HTML static: Angular îl ține legat de clasa componentei. Parantezele îți spun în ce direcție merg datele — {{ }} și [ ] duc valori din clasă în DOM, ( ) aduce evenimente înapoi în clasă, iar [( )] face amândouă.
Cele mai multe bug-uri de începător de aici nu sunt erori de sintaxă. Vin din trei întrebări: în ce direcție curg datele, setez o proprietate DOM sau un atribut HTML și ce element a primit de fapt click-ul?
Patru binding-uri, două direcții
Datele coboară cu {{ }} și [ ], evenimentele urcă cu ( ). Angular reevaluează expresiile din dreapta când se schimbă signal-urile pe care le citesc și scrie în DOM doar dacă rezultatul diferă de ce a scris data trecută.
| Sintaxă | Direcție | Ce se întâmplă |
|---|---|---|
{{ expr }} | clasă → DOM | setează textul unui nod (mereu text, niciodată HTML) |
[prop]="expr" | clasă → DOM | setează o proprietate a elementului DOM |
[attr.x]="expr" | clasă → DOM | setează sau scoate un atribut HTML |
(event)="stmt" | DOM → clasă | rulează o instrucțiune când apare evenimentul |
[(x)]="sig" | în ambele sensuri | un property binding plus un eveniment de schimbare, într-unul singur |
<!-- clasă → DOM -->
<h2>{{ title() }}</h2> <!-- interpolare: text -->
<img [src]="avatarUrl()" [alt]="userName()" /> <!-- property binding -->
<td [attr.colspan]="wide() ? 2 : null"></td> <!-- attribute binding -->
<!-- DOM → clasă -->
<button (click)="save()">Salvează</button> <!-- event binding -->
<!-- în ambele sensuri -->
<app-rating [(value)]="stars" /> <!-- two-way binding -->Interpolarea: mereu text
{{ }} transformă valoarea în string și o pune într-un nod de text. HTML-ul din valoare apare ca simple caractere, nu e interpretat — asta te protejează de XSS. Signal-urile se citesc apelându-le: {{ count() }}. Ține expresia ieftină și fără efecte secundare: poate rula la fiecare trecere de change detection.
<!-- valoarea devine text: '<b>Ana</b>' apare cu tot cu paranteze, nu îngroșat -->
<p>Salut, {{ userName() }}!</p>
<!-- orice expresie fără efecte secundare; signal-urile se apelează -->
<p>{{ items().length }} produse · {{ total() | currency: 'EUR' }}</p>
<p>{{ user()?.address?.city ?? 'încă fără oraș' }}</p>
<!-- ✗ textul funcției signal-ului, nu valoarea lui -->
<p>{{ count }}</p>Property binding setează o proprietate DOM
[value]="name()" nu atinge HTML-ul. Setează proprietatea value a elementului input. Atributele sunt valorile de pornire scrise în HTML; proprietățile sunt starea vie a elementului. Când utilizatorul tastează, proprietatea se schimbă, iar atributul rămâne cum era. Iar Angular scrie proprietatea doar când se schimbă name() — deci dacă în clasă e tot aceeași valoare, un reset nu schimbă ce a tastat utilizatorul.
@Component({
selector: 'app-profile',
template: `
<!-- setează input.value; nu schimbă atributul value="" -->
<input [value]="name()" (input)="name.set(box.value)" #box />
<button [disabled]="saving()" (click)="save()">Salvează</button>
<button (click)="reset()">Resetează</button>
`,
})
export class Profile {
readonly name = signal('Ana');
readonly saving = signal(false);
reset() {
// merge doar pentru că (input) ține name() la zi;
// doar cu [value], name() e tot 'Ana' și set('Ana') nu schimbă nimic
this.name.set('Ana');
}
}name()în clasă | 'Ana' |
input.valueproprietate DOM | 'Ana' |
getAttribute('value')atribut HTML | null |
sincronizatȘi câmpul, și signal-ul spun 'Ana'.
Primul buton e dezactivat: atributul există, iar valoarea lui text nu contează.
În primul mod, câmpul arată un text despre care clasa nu știe nimic. name.set('Ana') îi dă signal-ului valoarea pe care o avea deja, deci nu se schimbă nimic și Angular nu scrie în input. Să citești înapoi input-ul cu (input) — sau să legi în ambele sensuri cu [( )] ori cu un formular — ține clasa și DOM-ul în pas. Observă că getAttribute('value') rămâne tot timpul null: Angular n-a setat niciodată atributul.
Când nu există proprietate: [attr.]
Unele atribute nu au o proprietate corespunzătoare: aria-*, colspan, data-*, atributele de pe SVG. Pentru ele folosești [attr.nume]. O valoare null sau undefined scoate atributul — așa pornești și oprești aria-current. Clasele și stilurile au binding-urile lor, explicate în [class] și [style].
Atributele booleene sunt capcana clasică: browserul se uită doar dacă atributul există. disabled="false" e un string, atributul e prezent, iar butonul e dezactivat. [disabled]="false" setează proprietatea la booleanul false.
<!-- pentru acestea nu există proprietate DOM: legi atributul -->
<td [attr.colspan]="span()">…</td>
<svg><circle [attr.r]="radius()" /></svg>
<div [attr.data-state]="state()"></div>
<!-- null scoate atributul: aria-current există doar pe link-ul activ -->
<a [attr.aria-current]="active() ? 'page' : null" href="/orders">Comenzi</a>
<!-- ✗ atributul există, deci butonul e dezactivat -->
<button disabled="false">Salvează</button>
<!-- ✓ proprietatea primește booleanul false -->
<button [disabled]="false">Salvează</button>Event binding: ( )
(click)="save()" adaugă un listener pe element și rulează instrucțiunea când apare evenimentul. $event e obiectul evenimentului DOM: un MouseEvent pentru click-uri, un KeyboardEvent pentru taste sau valoarea emisă de un output() al unei componente. Ca să citești un câmp, o referință de template precum #box e mai simplă și complet tipată: (input)="name.set(box.value)".
<button (click)="save()">Salvează</button>
<!-- $event e evenimentul DOM; pentru un output() e valoarea emisă -->
<div (pointermove)="track($event.clientX, $event.clientY)"></div>
<app-rating (valueChange)="stars.set($event)" />
<!-- o referință de template are tipul HTMLInputElement: fără cast-uri -->
<input #box (input)="query.set(box.value)" />
<!-- ✗ în modul strict: 'value' does not exist on type 'EventTarget' -->
<input (input)="query.set($event.target.value)" />Evenimentele urcă (bubbling)
Un click pornește de pe cel mai adânc element și apoi urcă prin fiecare strămoș, rulând și handler-ele lor de (click). Așa ajunge un buton Șterge dintr-un rând clickabil să deschidă și rândul — handler-ul rândului rulează imediat după cel al butonului.
- Comanda #1042
- Comanda #1043
- Comanda #1044
Niciun click încă.
$event.target e elementul de pe care a pornit click-ul și e același în toate handler-ele. $event.currentTarget e elementul al cărui handler rulează acum. stopPropagation() în handler-ul interior e soluția rapidă. O structură mai bună e adesea să nu le imbrici deloc: acțiunea rândului devine un link sau un buton adevărat lângă Șterge, nu un handler de click pe tot rândul — așa îl pot deschide și cei care folosesc tastatura.
<ul>
@for (order of orders(); track order.id) {
<!-- rândul deschide comanda… -->
<li (click)="open(order)">
Comanda #{{ order.id }}
<!-- …iar fără stopPropagation(), un click aici ajunge și la rând -->
<button (click)="remove(order); $event.stopPropagation()">Șterge</button>
</li>
}
</ul>
<!-- adesea mai bine: două acțiuni separate, fără imbricare și accesibile din tastatură -->
<li>
<a [routerLink]="['/orders', order.id]">Comanda #{{ order.id }}</a>
<button (click)="remove(order)">Șterge</button>
</li>Filtre de taste și acțiunea implicită a browserului
Angular te lasă să filtrezi evenimentele de tastatură direct în binding: (keydown.enter), (keydown.escape), (keydown.shift.enter), (keydown.control.s). Modificatorii trebuie să se potrivească exact: (keydown.enter) nu rulează la Shift+Enter. Handler-ul tău rulează înainte de acțiunea implicită a browserului — un rând nou, trimiterea formularului, urmarea unui link — iar $event.preventDefault() anulează acțiunea.
<textarea
#msg
[value]="draft()"
(input)="draft.set(msg.value)"
(keydown.enter)="send($event)"
(keydown.escape)="draft.set('')"
></textarea>
<!-- (keydown.enter) nu rulează la Shift+Enter: browserul adaugă un rând, ca de obicei -->(keydown) | — |
(keydown.enter) | — |
(keydown.shift.enter) | — |
(keydown.escape) | — |
Apasă o tastă în caseta de mesaj.
Fără preventDefault(), browserul inserează rândul nou după ce handler-ul tău a golit draft, iar evenimentul (input) scrie textul înapoi. Încă un detaliu pe care majoritatea casetelor de chat îl greșesc: cât timp cineva scrie în japoneză sau chineză cu o metodă de introducere (IME), Enter confirmă caracterele. Verifică $event.isComposing și ignoră tasta în cazul ăsta.
[( )] înseamnă [ ] plus ( )
„Banana în cutie” e o prescurtare. [(value)]="name" înseamnă un property binding pe value plus un listener pentru evenimentul valueChange care scrie înapoi. Când dai un WritableSignal, Angular setează signal-ul în locul tău. În componentele tale, model() creează ambele jumătăți; pentru câmpuri de formular, folosește Signal Forms.
<!-- prescurtarea… -->
<app-rating [(value)]="stars" />
<!-- …înseamnă exact asta -->
<app-rating [value]="stars()" (valueChange)="stars.set($event)" />
<!-- cu un WritableSignal, Angular apelează set() în locul tău;
copilul îl declară cu: value = model(0) -->Variabile de referință în template
#nume pe un element îți dă elementul în restul template-ului; pe o componentă, îți dă instanța componentei. E cel mai rapid mod să citești un câmp sau să apelezi focus() fără să treci prin $event.target, al cărui tip e doar EventTarget. În clasă, aceeași referință o obții cu viewChild().
<!-- pe un element: elementul însuși -->
<input #search placeholder="Caută" />
<button (click)="search.focus()">Focus</button>
<!-- pe o componentă: instanța componentei -->
<app-video-player #player />
<button (click)="player.play()">Pornește</button>
<!-- în clasă: readonly search = viewChild<ElementRef<HTMLInputElement>>('search') -->Obiceiuri bune / de evitat
- Ține sursa de adevăr în clasă (un signal) și citește înapoi fiecare schimbare din DOM.
- Folosește referințe de template (#box) în loc de $event.target când ai nevoie de o valoare.
- Folosește [attr.] pentru aria-* și data-* și null ca să scoți un atribut.
- Funcții care fac muncă grea în {{ }} sau [ ] — rulează din nou la fiecare trecere.
- Atribute booleene statice cu valoare (disabled="false"): contează doar prezența atributului.
- stopPropagation() din obișnuință: ascunde evenimentul și de analytics, și de meniurile care se închid la click în afară, și de colegii tăi.
Capcane frecvente
- Un reset care nu resetează. Câmpul era legat într-un singur sens, utilizatorul a tastat, iar clasa ține încă valoarea veche. Dacă o setezi din nou, nu se schimbă nimic.
- Parantezele uitate.
{{ count }}afișează funcția signal-ului, nu valoarea lui. Compilatorul te avertizează; nu ignora avertismentul. - Rândul se deschide când apeși Șterge. Click-ul a urcat de la buton la rând. Oprește-l sau nu imbrica acțiuni.
- Un rând gol rămas după trimitere. Acțiunea implicită a lui Enter a rulat după handler. Apelează
$event.preventDefault(). - Scrii
$event.target.value.targete unEventTarget, deci în modul strict nu compilează. Folosește o referință de template sau citește-l în clasă, cu un cast.
Fișă rapidă
{{ expr }} text în DOM; HTML-ul e escapat; signal-urile se apelează: {{ count() }}
[prop]="expr" o proprietate DOM (value, disabled, src, checked) sau un input de componentă
[attr.x]="expr" un atribut HTML (aria-*, data-*, colspan, svg); null îl scoate
[class.x] / [style.x] o clasă sau un stil; vezi pagina [class] și [style]
(event)="stmt" un listener; $event = evenimentul DOM sau valoarea emisă
(keydown.enter) filtre de taste; modificatorii trebuie să se potrivească: shift.enter, control.s, escape
[(x)]="sig" [x]="sig()" + (xChange)="sig.set($event)"
#ref elementul sau instanța componentei, în template
când scrie Angular doar când valoarea diferă de ultima scrisă
atribut vs proprietate atribut = valoarea de pornire din HTML; proprietate = starea vie
bubbling un click rulează handler-ele fiecărui strămoș; target rămâne același
acțiuni implicite rulează după handler-ul tău; $event.preventDefault() le anulează