[ngModel] i (ngModelChange) w praktyce
W lekcji 17 rozpisałeś skrót [(ngModel)] na dwie części, żeby zobaczyć, jak działa. Teraz wykorzystasz tę wersję do czegoś, czego skrót nie umie: między pole a sygnał wstawisz własny kod — poprawisz wpisany tekst, wyczyścisz inne pole, zapiszesz historię zmian.
Czego się dziś nauczysz
- Wywołasz własną metodę w
(ngModelChange)i przekażesz jej nową wartość$event. - Zareagujesz na każdą zmianę pola — np. policzysz zmiany i zapiszesz historię.
- Poprawisz wpisywaną wartość, zanim trafi do sygnału (wielkie litery, bez spacji).
- Zmianą jednego pola wyczyścisz inne — bez dodatkowego
(change). - Obsłużysz listę checkboxów z tablicy przez
ngModel. - Wybierzesz właściwy sposób pracy z polem:
#pole,(input),[(ngModel)]albo wersję rozpisaną.
Przygotowanie: lekcje 17–24 — wiesz, z czego składa się [(ngModel)], i używasz go z różnymi kontrolkami. Przewidywany czas: 45 min z zadaniami.
Skrót, który czasem nie wystarcza
[(ngModel)]="nr" robi dokładnie jedno: przepisuje to, co wpisał użytkownik, do sygnału. A co, jeśli chcesz numer rejestracyjny zawsze wielkimi literami? Albo po zmianie kategorii wyczyścić wybór wydarzenia? W lekcji 22 ratowaliśmy się dodatkowym (change), w lekcji 20 — porzuciliśmy ngModel całkiem.
<input [(ngModel)]="nr">
<input [ngModel]="nr()" (ngModelChange)="nr.set($event)">
<!-- zamiast nr.set($event) możesz wywołać własną metodę: -->
<input [ngModel]="nr()" (ngModelChange)="ustawNumer($event)">
W (ngModelChange) $event to gotowa nowa wartość — tekst, liczba albo true/false — a nie obiekt zdarzenia jak w (input). Metoda decyduje, co z nią zrobić: zapisać bez zmian, poprawić, a przy okazji zmienić coś jeszcze.
| Sposób | Kiedy |
|---|---|
#pole i pole.value | wartość potrzebna tylko w chwili kliknięcia, kod nie zmienia pola (lekcja 08) |
(input)="…($event)" | bez FormsModule, gdy wystarczy reagować na pisanie (lekcja 08) |
[(ngModel)]="sygnal" | domyślnie — pole i sygnał mają mieć zawsze tę samą wartość |
[ngModel] + (ngModelChange)="metoda($event)" | gdy przy zmianie trzeba zrobić coś więcej niż przepisać wartość |
Reakcja na każdą zmianę
Pole „Nick” w wersji rozpisanej. Metoda zapisuje nową wartość, zwiększa licznik zmian i dopisuje wartość na początek historii (najwyżej 5 pozycji).
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('');
readonly zmian = signal(0);
readonly historia = signal<string[]>([]);
zmienNick(nowy: string): void {
this.nick.set(nowy);
this.zmian.update(n => n + 1);
this.historia.update(h => [nowy, ...h].slice(0, 5));
}
}
<div class="p-3" style="width: 26rem">
<label class="form-label" for="nick">Nick</label>
<input id="nick" name="nick" class="form-control mb-2"
[ngModel]="nick()" (ngModelChange)="zmienNick($event)">
<p class="mb-1">Zmian: {{ zmian() }}</p>
<p class="small text-secondary">Ostatnie wartości: {{ historia().join(' ← ') }}</p>
</div>
Zmian: 3
Ostatnie wartości: abc ← ab ← a
Każda litera — i każde skasowanie — to jedna zmiana. [nowy, ...h].slice(0, 5) wstawia nową wartość na początek i obcina tablicę do pięciu elementów.
Zatrzymaj się
Jak zrobić to samo skrótem [(ngModel)]? Da się — ale w którym miejscu kodu wtedy policzyłbyś zmiany?
Zmiana wartości w locie
Numer rejestracyjny pojazdu do listy parkingowej: zawsze wielkimi literami i bez spacji. Zamieniamy tekst w metodzie, zanim trafi do sygnału — pole od razu pokazuje poprawioną wersję.
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 nr = signal('');
readonly poprawny = computed(() => this.nr().length >= 4 && this.nr().length <= 8);
ustawNumer(tekst: string): void {
this.nr.set(tekst.toUpperCase().replaceAll(' ', ''));
}
}
<div class="p-3" style="width: 24rem">
<label class="form-label" for="nr">Numer rejestracyjny</label>
<input id="nr" name="nr" class="form-control font-monospace fs-4"
[ngModel]="nr()" (ngModelChange)="ustawNumer($event)">
<p class="mt-2" [class.text-success]="poprawny()" [class.text-danger]="!poprawny()">
{{ poprawny() ? 'Numer przyjęty: ' + nr() : 'Numer musi mieć od 4 do 8 znaków.' }}
</p>
</div>
[GLB12AB]
Numer przyjęty: GLB12AB
Spacja znika, litery robią się wielkie — w trakcie pisania. W sygnale też jest już poprawiona wersja, więc reszta programu nie musi o tym pamiętać. font-monospace to czcionka o stałej szerokości znaków.
Zatrzymaj się
Te same zamiany dałoby się robić w computed, a w polu zostawić to, co wpisał użytkownik. Która wersja jest lepsza dla użytkownika, a która — dla danych?
Zmiana jednego pola czyści drugie
Wracamy do list zależnych z lekcji 22, tym razem bez osobnego (change). Metoda wywoływana z (ngModelChange) ustawia kategorię, czyści wydarzenie i dopisuje historię — w jednym miejscu.
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 katalog: Record<string, string[]> = {
'Gry': ['Turniej retro', 'Planszówki'],
'Technika': ['Noc robotów', 'Warsztat druku 3D'],
};
readonly kategorie = Object.keys(this.katalog);
readonly kategoria = signal<string | null>(null);
readonly wydarzenie = signal<string | null>(null);
readonly historia = signal<string[]>([]);
readonly wydarzenia = computed(() => {
const k = this.kategoria();
return k === null ? [] : this.katalog[k];
});
zmienKategorie(nowa: string): void {
this.kategoria.set(nowa);
this.wydarzenie.set(null);
this.historia.update(h => [...h, nowa]);
}
}
<div class="p-3" style="width: 26rem">
<select name="kat" class="form-select mb-2"
[ngModel]="kategoria()" (ngModelChange)="zmienKategorie($event)">
<option [ngValue]="null" disabled>— kategoria —</option>
@for (k of kategorie; track k) { <option [ngValue]="k">{{ k }}</option> }
</select>
<select name="wyd" class="form-select mb-3" [(ngModel)]="wydarzenie">
<option [ngValue]="null" disabled>— wydarzenie —</option>
@for (w of wydarzenia(); track $index) { <option [ngValue]="w">{{ w }}</option> }
</select>
<p>Wybór: {{ kategoria() ?? '?' }} → {{ wydarzenie() ?? '?' }}</p>
<p class="small text-secondary">Historia kategorii: {{ historia().join(' → ') }}</p>
</div>
Wybór: Technika → ?
Historia kategorii: Gry → Technika
Wszystko dzieje się w jednej metodzie, którą łatwo przeczytać i przetestować.
Zatrzymaj się
Druga lista nadal używa skrótu [(ngModel)]. Dlaczego tu nie trzeba go rozpisywać?
Lista checkboxów — tym razem z ngModel
W lekcji 20 lista checkboxów z tablicy używała [checked] i (change). Z wersją rozpisaną wracamy do ngModel: pole pokazuje, czy opcja jest na liście, a zdarzenie dostaje gotowe true albo false.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly sprzet = ['Pad', 'Słuchawki', 'Klawiatura', 'Mysz'];
readonly wybrane = signal<string[]>(['Pad']);
ustaw(nazwa: string, zaznaczony: boolean): void {
this.wybrane.update(lista =>
zaznaczony ? [...lista, nazwa] : lista.filter(x => x !== nazwa)
);
}
}
<div class="p-3" style="width: 26rem">
<p class="fw-bold">Co przyniesiesz na turniej?</p>
@for (s of sprzet; track s; let i = $index) {
<div class="form-check">
<input [id]="'s' + i" [name]="'s' + i" class="form-check-input" type="checkbox"
[ngModel]="wybrane().includes(s)" (ngModelChange)="ustaw(s, $event)">
<label class="form-check-label" [for]="'s' + i">{{ s }}</label>
</div>
}
<p class="mt-3">Lista: {{ wybrane().join(', ') || 'nic' }}</p>
</div>
Lista: Pad, Słuchawki
Metoda ustaw nie musi sprawdzać, czy element był na liście — $event mówi wprost, czy checkbox jest teraz zaznaczony.
Zatrzymaj się
Porównaj metodę ustaw z metodą przelacz z lekcji 20. Która jest odporniejsza na sytuację, w której ekran i dane się rozjadą?
Zestawienie elementów
| Element | Znaczenie | Uwagi |
|---|---|---|
[ngModel]="s()" | klasa → pole | Odczyt z (). |
(ngModelChange)="metoda($event)" | pole → własna metoda | $event = nowa wartość. |
tekst.toUpperCase().replaceAll(' ', '') | wielkie litery bez spacji | Poprawianie przed zapisem. |
[nowy, ...h].slice(0, 5) | nowy na początku, max 5 | Historia zmian. |
[ngModel]="lista().includes(x)" | checkbox z tablicy | Z (ngModelChange). |
font-monospace | czcionka o stałej szerokości | Bootstrap. |
Zanim utkniesz
| Zapis | Problem |
|---|---|
[ngModel]="nr" (bez nawiasów) | Pole pokazuje opis sygnału, w terminalu NG8109. W wiązaniu jednostronnym sygnał odczytujesz: nr(). |
(ngModelChange)="ustaw($event.target.value)" | Błąd — $event w ngModelChange to już gotowa wartość, nie zdarzenie przeglądarki. |
Sam (ngModelChange) bez [ngModel] | Zdarzenie nigdy się nie wywoła — ngModel w ogóle nie jest podpięty do pola. |
Metoda z (ngModelChange) nie ustawia sygnału | Pole „ucieka” od danych: użytkownik widzi tekst, którego nie ma w sygnale. Metoda zawsze musi zrobić set. |
| Rozpisane wiązanie wszędzie „na zapas” | Działa, ale kod jest dłuższy. Rozpisuj tylko wtedy, gdy przy zmianie robisz coś więcej. |
(ngModelChange) i [(ngModel)] na tym samym polu | Działa, ale kolejność wykonania bywa myląca. Wybierz jedno. |
Zadania
Pole „Imię”, które w trakcie pisania samo zamienia pierwszą literę na wielką, a pozostałe na małe (wpisujesz „oLA” — w polu widać „Ola”).
Kryterium sukcesu: niezależnie od tego, jak wpiszesz imię, pole i sygnał zawierają poprawną postać.
Podpowiedź
t.charAt(0).toUpperCase() + t.slice(1).toLowerCase() w metodzie wywołanej z (ngModelChange).
Pole na kod pocztowy, które przyjmuje tylko cyfry i myślnik: każdy inny znak jest usuwany w trakcie pisania. Pod polem — informacja, czy kod ma poprawny format 00-000.
Kryterium sukcesu: litery i spacje nie pojawiają się w polu; komunikat zmienia się na zielony dla 84-300.
Podpowiedź
tekst.replace(/[^0-9-]/g, '') usuwa wszystko oprócz cyfr i myślnika. Format sprawdzisz wyrażeniem /^\d{2}-\d{3}$/.test(kod).
Formularz zamówienia koszulki: lista „model” (T-shirt, bluza) i grupa radio „rozmiar”. Dla T-shirtu dostępne są S–XL, dla bluzy M–XXL. Zmiana modelu ma czyścić wybrany rozmiar i pokazywać tylko rozmiary dostępne dla modelu.
Kryterium sukcesu: po zmianie modelu żaden rozmiar nie jest zaznaczony, a lista rozmiarów pasuje do modelu.
Podpowiedź
Model w wersji rozpisanej z metodą zmienModel, rozmiary z computed, radio w pętli @for.
Trzy suwaki: siła, zręczność, inteligencja (0–10). Razem można rozdać najwyżej 15 punktów. Gdy przesunięcie suwaka przekroczyłoby limit, wartość ma zostać obcięta tak, żeby suma wynosiła dokładnie 15. Pokaż sumę i liczbę pozostałych punktów.
Kryterium sukcesu: suma nigdy nie przekracza 15, a suwak „odbija” do największej dozwolonej wartości.
Podpowiedź
Każdy suwak w wersji rozpisanej z jedną metodą ustaw(nazwa, wartosc); dozwolone maksimum = 15 − suma pozostałych dwóch.
Dwa pola liczbowe: kwota w PLN i kwota w EUR (kurs 4,30). Wpisanie wartości w jedno pole przelicza drugie — w obie strony.
Kryterium sukcesu: pisanie w dowolnym polu aktualizuje drugie; wyniki są zaokrąglone do dwóch miejsc; puste pole czyści drugie.
Podpowiedź
Dwa sygnały i dwie metody wywoływane z (ngModelChange). Każda ustawia swój sygnał i wylicza drugi. Zaokrąglenie: Math.round(x * 100) / 100.
Pole textarea z przyciskami „Cofnij” i „Ponów”. Każda zmiana tekstu trafia do historii (najwyżej 20 wpisów). „Cofnij” przywraca poprzednią wersję tekstu w polu, „Ponów” — następną. Przyciski są nieaktywne, gdy nie ma czego cofnąć albo ponowić.
Kryterium sukcesu: można cofać i ponawiać wielokrotnie; nowa zmiana po cofnięciu kasuje „przyszłość”.
Podpowiedź
Sygnał z tablicą wersji i sygnał z numerem bieżącej wersji. W metodzie zmiany obetnij tablicę do bieżącego numeru (slice(0, nr + 1)) i dopisz nową wersję.
Co trzeba zapamiętać
[(ngModel)]="s"to skrót od[ngModel]="s()"i(ngModelChange)="s.set($event)".- W
(ngModelChange)możesz wywołać własną metodę;$eventto gotowa nowa wartość. - Wersja rozpisana pozwala poprawić wartość przed zapisem i zareagować na zmianę (np. wyczyścić inne pole).
- Metoda z
(ngModelChange)zawsze ustawia sygnał — inaczej pole i dane się rozjadą. - Domyślnie używaj skrótu; rozpisuj, gdy przy zmianie dzieje się coś więcej.
Sprawdź się: kiedy wybierzesz #pole, kiedy [(ngModel)], a kiedy wersję rozpisaną? Podaj po jednym przykładzie.
Dokumentacja: angular.dev — NgModel.