[(ngModel)] — jak działa wiązanie dwukierunkowe
Od tej lekcji każde pole formularza połączysz z danymi zapisem [(ngModel)]. Zanim zaczniesz używać go z różnymi kontrolkami, rozbierzesz go na części: co robią nawiasy kwadratowe, co okrągłe, skąd bierze się dyrektywa ngModel i dlaczego tu — wyjątkowo — sygnał piszesz bez nawiasów.
Czego się dziś nauczysz
- Wyjaśnisz, czym jest wiązanie dwukierunkowe i dlaczego
#poleoraz(input)z lekcji 08 dają tylko jeden kierunek. - Zaimportujesz
FormsModulei powiesz, skąd bierze się dyrektywangModel. - Połączysz pole tekstowe z sygnałem zapisem
[(ngModel)]="sygnal"— bez nawiasów przy sygnale. - Rozpiszesz skrót na
[ngModel]i(ngModelChange)i powiesz, za który kierunek odpowiada każda część. - Zmienisz zawartość pola z kodu, ustawiając sygnał.
- Połączysz dwa pola z jednym sygnałem i zbudujesz z niego
computed. - Dodasz polu atrybuty
nameiidoraz etykietęlabel for. - Rozpoznasz błędy NG8002, NG5002 i ostrzeżenie NG8101.
Przygotowanie: lekcje 06, 08 i 09–11 — znasz [ ], ( ), #pole, sygnały i computed. Przewidywany czas: 2 × 45 min z zadaniami.
Dwa kierunki naraz
Pomyśl o formularzu zapisu. Użytkownik wpisuje imię — klasa musi je poznać. Użytkownik klika „Wyczyść” — pole musi zrobić się puste. Ładujesz zapisane dane — pola muszą się wypełnić. Dane płyną więc w dwie strony: z pola do klasy i z klasy do pola.
| Sposób | Pole → klasa | Klasa → pole | Uwagi |
|---|---|---|---|
#pole i pole.value (lekcja 08) | tylko w chwili kliknięcia | ręcznie: pole.value = '' | klasa nie wie, co jest w polu |
(input)="…($event)" (lekcja 08) | przy każdej literze | nie | trzeba wyciągać tekst z $event.target |
[value]="tekst()" (lekcja 06) | nie | tak | pole tylko pokazuje wartość |
[(ngModel)]="tekst" | tak | tak | pole i sygnał mają zawsze tę samą wartość |
Wiązanie dwukierunkowe to po prostu oba kierunki naraz: wiązanie właściwości [ ] (klasa → pole) i zdarzenie ( ) (pole → klasa) zapisane jednym znakiem.
Co jest pod spodem: dyrektywa ngModel
1. ngModel to dyrektywa z FormsModule
Zwykły <input> nie ma właściwości ngModel. Dodaje mu ją dyrektywa — kawałek kodu Angulara, który „przykleja się” do elementu i daje mu nowe możliwości. Dyrektywa NgModel mieszka w pakiecie @angular/forms, w zestawie FormsModule. Dlatego każdy komponent z ngModel ma w imports wpis FormsModule — bez niego Angular nie rozpozna zapisu i zgłosi błąd NG8002.
2. Dyrektywa ma wejście i wyjście
NgModel ma wejście ngModel (wartość, którą ma pokazać pole) i wyjście ngModelChange (zdarzenie z nową wartością po każdej zmianie w polu). Z tymi dwiema częściami pracujesz tak samo jak z [title] i (click):
<input [(ngModel)]="imie">
<input [ngModel]="imie()" (ngModelChange)="imie.set($event)">
| Część | Kierunek | Co robi |
|---|---|---|
[ngModel]="imie()" | klasa → pole | pole pokazuje wartość sygnału; przy każdej zmianie sygnału pole się aktualizuje |
(ngModelChange)="imie.set($event)" | pole → klasa | po każdej zmianie w polu do sygnału trafia nowa wartość; $event to gotowy tekst, a nie obiekt zdarzenia |
[(ngModel)]="imie" | oba | skrót, który Angular sam rozpisuje na dwie linijki powyżej |
3. „Banan w pudełku” i sygnał bez nawiasów
Zapis [( )] łatwo pomylić z ([ ]). Zapamiętaj obrazek: banan () leży w pudełku []. W skrócie piszesz samą nazwę sygnału, bez (), bo Angular potrzebuje całego sygnału — będzie go i czytał (imie()), i zmieniał (imie.set(…)). Gdybyś napisał imie(), dostałby tylko tekst, do którego nie da się nic zapisać. Wszędzie indziej odczytujesz sygnał jak zwykle: {{ imie() }}.
Zwykłe pole też zadziała
[(ngModel)]="imie" działa także ze zwykłym polem klasy (imie = '') — Angular wtedy po prostu przypisuje do niego nowy tekst. W tym kursie używamy sygnałów: wtedy z wartości pola korzystają computed i wszystko, czego nauczyłeś się w lekcjach 09–11, a ekran odświeża się także po zmianach spoza szablonu.
4. name, id i etykieta
Każdemu polu z ngModel nadawaj atrybut name. Poza znacznikiem <form> nie jest wymagany, ale wewnątrz formularza (lekcja 24) bez niego Angular zgłosi błąd. Etykieta <label for="imie"> i pole id="imie" z tą samą wartością sprawiają, że kliknięcie w napis ustawia kursor w polu, a czytnik ekranu wie, czego pole dotyczy.
Pole → sygnał: podgląd na żywo
Najprostsze połączenie: jedno pole, jeden sygnał, jeden napis pod spodem.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly imie = signal('');
}
<div class="p-3" style="width: 24rem">
<label class="form-label" for="imie">Twoje imię</label>
<input id="imie" name="imie" class="form-control" [(ngModel)]="imie">
<p class="mt-3 fs-5">Cześć, {{ imie() }}!</p>
</div>
Twoje imię
[Ola ]
Cześć, Ola!
Napis nadąża za każdą literą. Klasa nie ma ani jednej metody: wartość z pola trafia do sygnału sama, przez ngModelChange ukryte w skrócie.
Zatrzymaj się
Porównaj z lekcją 08, przykład 3. Ile linijek w klasie było tam potrzebnych, żeby wyciągnąć tekst z pola, a ile tutaj?
Sygnał → pole: kod zmienia zawartość pola
Teraz kierunek, którego #pole i (input) nie dają za darmo: przyciski ustawiają sygnał, a pole samo pokazuje nową wartość.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly nick = signal('');
przyklad(): void {
this.nick.set('Pixel2026');
}
wyczysc(): void {
this.nick.set('');
}
}
<div class="p-3" style="width: 24rem">
<label class="form-label" for="nick">Nick w turnieju</label>
<input id="nick" name="nick" class="form-control mb-2" [(ngModel)]="nick">
<button class="btn btn-outline-primary me-2" (click)="przyklad()">Wpisz przykładowy</button>
<button class="btn btn-outline-danger" (click)="wyczysc()">Wyczyść</button>
<p class="mt-3">W sygnale jest: „{{ nick() }}”</p>
</div>
[Pixel2026 ]
W sygnale jest: „Pixel2026”
„Wyczyść” opróżnia pole, choć metoda zmienia tylko sygnał. Dopisz coś ręcznie po przykładowym nicku — sygnał od razu ma nową wartość. W klasie nie ma ani słowa o polu tekstowym.
Zatrzymaj się
Metoda wyczysc zmienia tylko sygnał. Która część rozpisanego skrótu sprawia, że pole się opróżnia?
Skrót i wersja rozpisana obok siebie
Dwa pola połączone z dwoma sygnałami: pierwsze skrótem, drugie wersją rozpisaną z teorii. Sprawdź, że zachowują się identycznie — także po kliknięciu „Wyczyść oba”.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly a = signal('');
readonly b = signal('');
wyczysc(): void {
this.a.set('');
this.b.set('');
}
}
<div class="p-3" style="width: 28rem">
<label class="form-label" for="a">Skrót: [(ngModel)]</label>
<input id="a" name="a" class="form-control mb-1" [(ngModel)]="a">
<p class="small">a() = „{{ a() }}”</p>
<label class="form-label" for="b">Rozpisane: [ngModel] + (ngModelChange)</label>
<input id="b" name="b" class="form-control mb-1" [ngModel]="b()" (ngModelChange)="b.set($event)">
<p class="small">b() = „{{ b() }}”</p>
<button class="btn btn-outline-secondary" (click)="wyczysc()">Wyczyść oba</button>
</div>
a() = „abc”
b() = „abc”
Nie ma żadnej różnicy w działaniu. W wersji rozpisanej przy [ngModel] są nawiasy (odczyt sygnału), a w (ngModelChange) jest set z $event — czyli dokładnie to, co Angular dopisuje za Ciebie w skrócie. W lekcji 25 wykorzystasz wersję rozpisaną, żeby między odczytem a zapisem wstawić własny kod.
Zatrzymaj się
W [ngModel]="b()" są nawiasy, a w [(ngModel)]="a" nie ma. Wyjaśnij to jednym zdaniem.
Jeden sygnał, dwa pola i computed
Dwa pola połączone z tym samym sygnałem. Pisanie w jednym zmienia sygnał, a sygnał — drugie pole. Z sygnału liczą się dwa computed.
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly nick = signal('');
readonly dlugosc = computed(() => this.nick().trim().length);
readonly email = computed(() => this.nick().trim().toLowerCase() + '@klub.zsmi.pl');
}
<div class="p-3" style="width: 28rem">
<label class="form-label" for="nick1">Nick — pole 1</label>
<input id="nick1" name="nick1" class="form-control mb-2" [(ngModel)]="nick">
<label class="form-label" for="nick2">Nick — pole 2 (ten sam sygnał)</label>
<input id="nick2" name="nick2" class="form-control" [(ngModel)]="nick">
<p class="mt-3 mb-1">Znaków: {{ dlugosc() }}</p>
<p>Adres w klubie: {{ email() }}</p>
</div>
Nick — pole 1 [Pixel ]
Nick — pole 2 [Pixel ]
Znaków: 5
Adres w klubie: pixel@klub.zsmi.pl
To najlepszy dowód, że dane naprawdę płyną w obie strony: pole 1 → sygnał → pole 2. Pola mają różne name i id, bo to dwa różne elementy strony — wspólny jest tylko sygnał.
Zatrzymaj się
W computed nie ma ani słowa o polach, a mimo to przelicza się przy każdym naciśnięciu klawisza. Co je łączy?
Zestawienie elementów
| Element | Znaczenie | Uwagi |
|---|---|---|
import { FormsModule } from '@angular/forms' | zestaw dyrektyw formularzy | Do imports komponentu. |
[(ngModel)]="sygnal" | wiązanie dwukierunkowe | Sygnał bez (). |
[ngModel]="sygnal()" | klasa → pole | Odczyt z (). |
(ngModelChange)="sygnal.set($event)" | pole → klasa | $event = nowa wartość. |
name="…" | nazwa pola | Obowiązkowa w <form>. |
<label for="x"> + id="x" | etykieta połączona z polem | Klik w napis = kursor w polu. |
form-label, form-control | etykieta i pole Bootstrapa | Z lekcji 08 i 12. |
| NG8002 | nieznana właściwość ngModel | Brak FormsModule. |
| NG5002 Unsupported expression in a two-way binding | nawiasy przy sygnale w skrócie | Usuń (). |
| NG8101 | odwrócone nawiasy ([ ]) | Banan w pudełku: [( )]. |
Zanim utkniesz
| Zapis | Problem |
|---|---|
Brak FormsModule w imports | NG8002: Can’t bind to 'ngModel’ since it isn’t a known property of 'input’. Ten komunikat zawsze oznacza brakujący import. |
[(ngModel)]="imie()" | NG5002: Unsupported expression in a two-way binding. Do wyniku odczytu nie da się nic zapisać — podaj sam sygnał. |
([ngModel])="imie" | Ostrzeżenie NG8101, a pole nie działa. Nawiasy w złej kolejności — banan ma być w pudełku: [( )]. |
[ngModel]="imie()" bez (ngModelChange) | Pole pokazuje wartość sygnału, ale pisanie go nie zmienia — to tylko jeden kierunek. |
(ngModelChange)="imie.set($event.target.value)" | Błąd — w ngModelChange $event to już gotowy tekst, nie obiekt zdarzenia. |
Pole z ngModel w <form> bez name | Błąd w konsoli przeglądarki: If ngModel is used within a form tag, either the name attribute must be set… Dopisz name. |
for="imie" i id="Imie" | Działa, ale kliknięcie w etykietę nie ustawia kursora. Wielkość liter w id ma znaczenie. |
Zadania
Pole „Miasto” i napis pod nim: „Pozdrowienia dla mieszkańców miasta …!”. Dodaj przycisk „Lębork”, który wpisuje tę nazwę do pola, i przycisk „Wyczyść”.
Kryterium sukcesu: napis zmienia się podczas pisania, a przyciski zmieniają jednocześnie pole i napis.
Podpowiedź
Jeden sygnał, [(ngModel)] i dwie metody z set.
Weź przykład 1 i zamień skrót [(ngModel)] na wersję rozpisaną. Sprawdź, że wszystko działa tak samo. Potem usuń (ngModelChange) i zapisz w komentarzu HTML, co przestało działać i dlaczego. Na koniec przywróć skrót.
Kryterium sukcesu: wersja rozpisana działa identycznie, a komentarz poprawnie opisuje utracony kierunek.
Podpowiedź
[ngModel]="imie()" (ngModelChange)="imie.set($event)".
Dwa pola: imię i nazwisko. Pod nimi login: pierwsze trzy litery imienia + pierwsze trzy litery nazwiska, małymi literami, np. jankow. Gdy któreś pole ma mniej niż 3 znaki, zamiast loginu pokaż szary napis „za krótko”.
Kryterium sukcesu: login aktualizuje się w trakcie pisania i zawsze składa się z małych liter.
Podpowiedź
computed ze slice(0, 3) i toLowerCase(), warunek — @if.
Trzy pola (imię, nazwisko, klasa) po lewej i karta po prawej (siatka col-md-6). Karta pokazuje duże inicjały, imię i nazwisko oraz klasę. Gdy pola są puste, zamiast inicjałów widać „?”, a zamiast klasy — kreskę.
Kryterium sukcesu: karta wypełnia się w miarę pisania; puste pola dają „?” i „—”.
Podpowiedź
Inicjały: computed(() => (this.imie().charAt(0) + this.nazwisko().charAt(0)).toUpperCase()). Wartość zastępczą da operator ||: {{ klasa() || '—' }}. Duży tekst: klasa display-6.
Formularz ogłoszenia z trzema polami: tytuł, sala, godzina. Nad nim trzy przyciski z gotowymi szablonami („Turniej retro / 12 / 16:00”, „Noc robotów / 7 / 18:00”, „Druk 3D / 14 / 14:30”), które wypełniają wszystkie trzy pola naraz. Użytkownik może potem poprawić dowolne pole. Pod formularzem podgląd ogłoszenia jako karta, a przycisk „Wyczyść” opróżnia wszystko.
Kryterium sukcesu: przyciski wypełniają pola, ręczne poprawki od razu trafiają do podglądu, a „Wyczyść” czyści pola i kartę.
Podpowiedź
Jedna metoda wypelnij(tytul, sala, godzina) z trzema set. Kierunek „klasa → pole” zrobi za Ciebie [(ngModel)].
Zrób dwa pola połączone z dwoma różnymi sygnałami: „Tekst” i „Tekst od tyłu”. Pisanie w pierwszym wpisuje do drugiego odwrócony tekst, a pisanie w drugim — odwrócony do pierwszego. Użyj wersji rozpisanej w obu polach.
Kryterium sukcesu: niezależnie od tego, w którym polu piszesz, drugie zawsze zawiera ten sam tekst czytany od końca.
Podpowiedź
W (ngModelChange) wywołaj metodę, która ustawia oba sygnały. Odwrócenie tekstu: tekst.split('').reverse().join('').
Co trzeba zapamiętać
- Wiązanie dwukierunkowe to wiązanie właściwości
[ ]i zdarzenie( )naraz: pole i dane mają zawsze tę samą wartość. ngModelto dyrektywa zFormsModule— bez importu dostaniesz NG8002.[(ngModel)]="s"to skrót od[ngModel]="s()"i(ngModelChange)="s.set($event)".- W skrócie podajesz sam sygnał, bez nawiasów; odczyt w innych miejscach — jak zwykle, z nawiasami.
- W
(ngModelChange)$eventto gotowa nowa wartość, nie obiekt zdarzenia. - Zmiana sygnału w kodzie od razu zmienia zawartość pola; jeden sygnał może obsługiwać kilka pól.
- Każde pole dostaje
name, a etykietaforwskazujeidpola.
Sprawdź się: czym różni się odczyt pola przez #pole od połączenia przez [(ngModel)]? Podaj sytuację, w której zmienna szablonowa nie wystarczy.
Dokumentacja: angular.dev — wiązanie dwukierunkowe.