Przejdź do treści
School IT / Przestrzeń do nauki

[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.

Angular 22 [ngModel] (ngModelChange) poprawianie wartości listy zależne checkboxy z tablicy 45 min
CEL LEKCJI

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.

TEORIA

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.

przypomnienie z lekcji 17
<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óbKiedy
#pole i pole.valuewartość 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ść
PRZYKŁAD 1

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).

src/app/nowa-25-1/demo.ts — pełny plik
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));
    }
}
src/app/nowa-25-1/demo.html — pełny plik
<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>
wynik po wpisaniu „abc”
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?

PRZYKŁAD 2

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ę.

src/app/nowa-25-2/demo.ts — pełny plik
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(' ', ''));
    }
}
src/app/nowa-25-2/demo.html — pełny plik
<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>
wynik po wpisaniu „glb 12ab”
[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?

PRZYKŁAD 3

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.

src/app/nowa-25-3/demo.ts — pełny plik
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]);
    }
}
src/app/nowa-25-3/demo.html — pełny plik
<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>
wynik: Gry → Turniej retro, potem Technika
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ć?

PRZYKŁAD 4

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.

src/app/nowa-25-4/demo.ts — pełny plik
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)
        );
    }
}
src/app/nowa-25-4/demo.html — pełny plik
<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>
wynik po zaznaczeniu Słuchawek
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ą?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenieUwagi
[ngModel]="s()"klasa → poleOdczyt z ().
(ngModelChange)="metoda($event)"pole → własna metoda$event = nowa wartość.
tekst.toUpperCase().replaceAll(' ', '')wielkie litery bez spacjiPoprawianie przed zapisem.
[nowy, ...h].slice(0, 5)nowy na początku, max 5Historia zmian.
[ngModel]="lista().includes(x)"checkbox z tablicyZ (ngModelChange).
font-monospaceczcionka o stałej szerokościBootstrap.
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
[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łuPole „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 poluDziała, ale kolejność wykonania bywa myląca. Wybierz jedno.
ZADANIA

Zadania

ZAD 1Imię wielką literą★☆☆

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).

ZAD 2Kod pocztowy★☆☆

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).

ZAD 3Zmiana resetuje★★☆

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.

ZAD 4Limit punktów★★☆

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.

ZAD 5Kwota i waluta w obie strony★★★

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.

ZAD 6Edytor z cofaniem★★★

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ę.

PODSUMOWANIE

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ę; $event to 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.

Postęp zapisuje się w tej przeglądarce.