output() — dziecko zgłasza zdarzenie
Wejścia przenoszą dane od rodzica do dziecka, a dziecko nie może ich zmienić. Jak więc przycisk „Zapisz się” na karcie ma cokolwiek zapisać? Dziecko wysyła zdarzenie — tak jak przycisk wysyła (click) — a rodzic na nie reaguje.
Czego się dziś nauczysz
- Zadeklarujesz w dziecku zdarzenie
output()i wyślesz je metodąemit. - Odbierzesz zdarzenie u rodzica zapisem
(nazwa)="metoda()". - Prześlesz razem ze zdarzeniem dane i odczytasz je u rodzica przez
$event. - Usuniesz element z listy rodzica na prośbę karty — dziecko zgłasza, rodzic decyduje.
- Wydzielisz formularz do osobnego komponentu, który oddaje rodzicowi gotowy obiekt.
Przygotowanie: lekcja 08 (zdarzenia ( ), $event), lekcje 29–30 (input, interfejs), lekcja 24 ((ngSubmit)). Przewidywany czas: 45 min z zadaniami.
Dane w dół, zdarzenia w górę
Gdyby karta mogła zmieniać dane rodzica, każda z dziesięciu kart mogłaby po cichu psuć wspólną listę i trudno byłoby znaleźć winnego. Dlatego w Angularze obowiązuje prosta zasada: dane zmienia ich właściciel. Dziecko tylko zgłasza: „kliknięto mnie”, „usuń wydarzenie nr 2”. Rodzic decyduje, co z tym zrobić.
| Krok | Kod | Gdzie |
|---|---|---|
| 1. Dziecko deklaruje zdarzenie | readonly zapisz = output<void>(); | karta.ts; void — zdarzenie bez danych |
| 2. Dziecko wysyła zdarzenie | this.zapisz.emit(); | w metodzie wywołanej z (click) |
| 2a. …z danymi | this.wybrano.emit(this.tytul()); | przy output<string>() |
| 3. Rodzic nasłuchuje | <app-karta (zapisz)="dodajZapis()" /> | demo.html |
| 3a. …i odbiera dane | (wybrano)="wybor.set($event)" | $event = wartość z emit |
Nazwy zdarzeń
Nazwę wybierasz jak nazwę metody: czasownik albo opis tego, co się stało — zapisz, usun, wybrano. Nie dodawaj przedrostka on ani nie nazywaj zdarzenia jak zdarzenie przeglądarki (click, change) — (click) na karcie myliłby się z kliknięciem w element.
Zdarzenie bez danych
Karta ma przycisk „Zapisz się”, który wysyła zdarzenie zapisz. Rodzic liczy wszystkie zapisy ze wszystkich kart.
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-karta',
imports: [],
templateUrl: './karta.html'
})
export class Karta {
readonly tytul = input.required<string>();
readonly zapisz = output<void>();
kliknieto(): void {
this.zapisz.emit();
}
}
<div class="card p-3 mb-2" style="width: 20rem">
<h5>{{ tytul() }}</h5>
<button class="btn btn-sm btn-primary align-self-start" (click)="kliknieto()">Zapisz się</button>
</div>
import { Component, signal } from '@angular/core';
import { Karta } from './karta';
@Component({
selector: 'app-demo',
imports: [Karta],
templateUrl: './demo.html'
})
export class Demo {
readonly zapisow = signal(0);
dodajZapis(): void {
this.zapisow.update(n => n + 1);
}
}
<div class="p-3">
<p class="fs-5">Wszystkich zapisów: {{ zapisow() }}</p>
<app-karta tytul="Turniej retro" (zapisz)="dodajZapis()" />
<app-karta tytul="Noc robotów" (zapisz)="dodajZapis()" />
</div>
Wszystkich zapisów: 3
Licznik jest u rodzica; karty nie wiedzą o jego istnieniu. Każda tylko zgłasza „kliknięto mnie”. Droga zdarzenia: (click) w karcie → metoda kliknieto() → emit() → (zapisz) u rodzica → dodajZapis().
Zatrzymaj się
Metoda kliknieto robi tylko jedną rzecz. Czy dałoby się zapisać (click)="zapisz.emit()" bezpośrednio w szablonie karty? (Podpowiedź: przykład 3.)
Zdarzenie z danymi: $event
Teraz karta wysyła swój tytuł. Rodzic wie dzięki temu, która karta została kliknięta, i pokazuje ostatni wybór.
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-karta',
imports: [],
templateUrl: './karta.html'
})
export class Karta {
readonly tytul = input.required<string>();
readonly wybrano = output<string>();
wybierz(): void {
this.wybrano.emit(this.tytul());
}
}
<div class="card p-3 mb-2" style="width: 20rem">
<h5>{{ tytul() }}</h5>
<button class="btn btn-sm btn-outline-primary align-self-start" (click)="wybierz()">Wybierz</button>
</div>
import { Component, signal } from '@angular/core';
import { Karta } from './karta';
@Component({
selector: 'app-demo',
imports: [Karta],
templateUrl: './demo.html'
})
export class Demo {
readonly wybor = signal('nic');
}
<div class="p-3">
<p class="fs-5">Twój wybór: <strong>{{ wybor() }}</strong></p>
<app-karta tytul="Turniej retro" (wybrano)="wybor.set($event)" />
<app-karta tytul="Noc robotów" (wybrano)="wybor.set($event)" />
<app-karta tytul="Warsztat druku 3D" (wybrano)="wybor.set($event)" />
</div>
Twój wybór: Warsztat druku 3D
$event to dokładnie to, co dziecko przekazało do emit — tu tekst. Typ w nawiasach ostrych (output<string>) pilnuje obu stron: dziecko musi wysłać tekst, a rodzic dostaje tekst.
Zatrzymaj się
Porównaj $event tutaj i w (input) z lekcji 08. Czym się różnią?
Usuwanie z listy — decyzja u rodzica
Karty powstają w pętli. Każda ma przycisk „Odwołaj”, który wysyła id wydarzenia — tym razem bez osobnej metody, emit wprost w szablonie. Rodzic, właściciel tablicy, usuwa element i dopisuje jego tytuł do listy odwołanych.
export interface Wydarzenie {
id: number;
tytul: string;
sala: string;
}
import { Component, input, output } from '@angular/core';
import { Wydarzenie } from './wydarzenie';
@Component({
selector: 'app-karta',
imports: [],
templateUrl: './karta.html'
})
export class Karta {
readonly wydarzenie = input.required<Wydarzenie>();
readonly odwolaj = output<number>();
}
<div class="card p-3 h-100">
<h5>{{ wydarzenie().tytul }}</h5>
<p class="mb-2">Sala {{ wydarzenie().sala }}</p>
<button class="btn btn-sm btn-outline-danger mt-auto"
(click)="odwolaj.emit(wydarzenie().id)">Odwołaj</button>
</div>
import { Component, signal } from '@angular/core';
import { Karta } from './karta';
import { Wydarzenie } from './wydarzenie';
@Component({
selector: 'app-demo',
imports: [Karta],
templateUrl: './demo.html'
})
export class Demo {
readonly wydarzenia = signal<Wydarzenie[]>([
{ id: 1, tytul: 'Turniej retro', sala: '12' },
{ id: 2, tytul: 'Noc robotów', sala: '7' },
{ id: 3, tytul: 'Warsztat druku 3D', sala: '14' },
]);
readonly odwolane = signal<string[]>([]);
usun(id: number): void {
const w = this.wydarzenia().find(x => x.id === id);
if (w) {
this.odwolane.update(l => [...l, w.tytul]);
}
this.wydarzenia.update(lista => lista.filter(x => x.id !== id));
}
}
<div class="p-3">
<div class="row g-3" style="max-width: 50rem">
@for (w of wydarzenia(); track w.id) {
<div class="col-md-4">
<app-karta [wydarzenie]="w" (odwolaj)="usun($event)" />
</div>
} @empty {
<p class="text-secondary">Wszystkie wydarzenia odwołane.</p>
}
</div>
<p class="mt-3 small">Odwołane: {{ odwolane().join(', ') || 'brak' }}</p>
</div>
Turniej retro Warsztat druku 3D
Sala 12 Sala 14
[Odwołaj] [Odwołaj]
Odwołane: Noc robotów
Usuń wszystkie — pojawi się komunikat z @empty. find szuka elementu o podanym id, a filter zostawia wszystkie pozostałe (lekcja 13). Cała logika — usuwanie i historia — jest w jednym miejscu: u rodzica.
Zatrzymaj się
Dlaczego karta wysyła id, a nie numer pozycji w tablicy? Co by się stało, gdyby dwie osoby jednocześnie usuwały elementy z tej samej listy?
Formularz jako osobny komponent
Formularz dodawania wydarzenia to osobny komponent app-formularz. Ma własne pola i własne sygnały, a po zatwierdzeniu wysyła gotowy obiekt zdarzeniem dodano. Rodzic dopisuje go do listy i nadaje mu id.
import { Component, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-formularz',
imports: [FormsModule],
templateUrl: './formularz.html'
})
export class Formularz {
readonly dodano = output<{ tytul: string; sala: string }>();
readonly tytul = signal('');
readonly sala = signal('');
zatwierdz(): void {
if (this.tytul().trim() === '' || this.sala().trim() === '') {
return;
}
this.dodano.emit({ tytul: this.tytul().trim(), sala: this.sala().trim() });
this.tytul.set('');
this.sala.set('');
}
}
<form class="card p-3" (ngSubmit)="zatwierdz()">
<h6>Nowe wydarzenie</h6>
<input name="tytul" class="form-control mb-2" placeholder="Tytuł" [(ngModel)]="tytul">
<input name="sala" class="form-control mb-2" placeholder="Sala" [(ngModel)]="sala">
<button class="btn btn-success" type="submit">Dodaj</button>
</form>
import { Component, signal } from '@angular/core';
import { Formularz } from './formularz';
@Component({
selector: 'app-demo',
imports: [Formularz],
templateUrl: './demo.html'
})
export class Demo {
nastepneId = 3;
readonly wydarzenia = signal([
{ id: 1, tytul: 'Turniej retro', sala: '12' },
{ id: 2, tytul: 'Noc robotów', sala: '7' },
]);
dodaj(dane: { tytul: string; sala: string }): void {
this.wydarzenia.update(lista => [...lista, { id: this.nastepneId, ...dane }]);
this.nastepneId++;
}
}
<div class="p-3">
<div class="row g-4" style="max-width: 50rem">
<div class="col-md-5">
<app-formularz (dodano)="dodaj($event)" />
</div>
<div class="col-md-7">
<ul class="list-group">
@for (w of wydarzenia(); track w.id) {
<li class="list-group-item">#{{ w.id }} {{ w.tytul }} — sala {{ w.sala }}</li>
}
</ul>
</div>
</div>
</div>
#1 Turniej retro — sala 12
#2 Noc robotów — sala 7
#3 Gra miejska — sala 5
(formularz wyczyszczony)
Typ { tytul: string; sala: string } wpisany wprost w output<…> powtarza się u rodzica — w prawdziwym projekcie zrobiłbyś z niego interfejs, jak w lekcji 30. Zapis { id: this.nastepneId, ...dane } buduje nowy obiekt z id i wszystkimi polami z dane. nastepneId to zwykłe pole, bo nie jest wyświetlane w szablonie.
Zatrzymaj się
Kto nadaje id — formularz czy rodzic? Dlaczego to dobre miejsce?
Zestawienie elementów
| Element | Znaczenie |
|---|---|
output z @angular/core | funkcja tworząca zdarzenie komponentu |
readonly x = output<void>() | zdarzenie bez danych |
readonly x = output<number>() | zdarzenie niosące liczbę (albo tekst, obiekt…) |
this.x.emit() / this.x.emit(wartosc) | wysłanie zdarzenia z klasy |
(click)="x.emit(w().id)" | wysłanie zdarzenia wprost z szablonu dziecka |
<app-karta (x)="metoda($event)" /> | nasłuch u rodzica; $event = wartość z emit |
lista.find(e => e.id === id) | pierwszy element spełniający warunek albo undefined |
{ id: 3, ...dane } | nowy obiekt z polem id i polami skopiowanymi z dane |
Zanim utkniesz
| Zapis | Problem |
|---|---|
U rodzica (zapisano), w dziecku zdarzenie zapisz | Brak błędu kompilacji — Angular uznaje (zapisano) za zdarzenie przeglądarki, które nigdy nie nadejdzie. Po kliknięciu nic się nie dzieje. Sprawdź nazwy. |
this.zapisz() zamiast this.zapisz.emit() | TS2349: This expression is not callable. Type 'OutputEmitterRef<void>’ has no call signatures. |
this.wybrano.emit() przy output<string> | TS2554: Expected 1 arguments, but got 0. Przekaż wartość albo zmień typ na void. |
(odwolaj)="usun()", gdy usun(id: number) | TS2554: Expected 1 arguments, but got 0. Przekaż $event: usun($event). |
Brak Karta w imports rodzica | NG8001: ’app-karta’ is not a known element (lekcja 04). |
| Dziecko próbuje samo usunąć element z listy rodzica | Nie da się — dziecko nie ma dostępu do tablicy rodzica. I bardzo dobrze: dziecko zgłasza, rodzic decyduje. |
Zadania
Komponent app-zdjecie z obowiązkowym wejściem opis i zdarzeniem polubiono. Rodzic pokazuje trzy „zdjęcia” (wystarczy szary prostokąt bg-secondary-subtle z opisem) i łączną liczbę polubień.
Kryterium sukcesu: przyciski na wszystkich zdjęciach zwiększają jeden wspólny licznik u rodzica.
Podpowiedź
Przykład 1 z innymi nazwami.
Komponent app-kolor z wejściem kolor (np. '#0d6efd') rysuje kwadrat w tym kolorze i po kliknięciu wysyła zdarzenie wybrano z nazwą koloru. Rodzic pokazuje sześć kwadratów i napis „Wybrany kolor: …” w wybranym kolorze.
Kryterium sukcesu: kliknięcie w kwadrat zmienia napis i jego kolor u rodzica.
Podpowiedź
output<string>(), a u rodzica (wybrano)="wybrany.set($event)" i [style.color]="wybrany()".
Rodzic ma tablicę trzech kandydatów do samorządu (id, imie, glosy). Komponent app-kandydat dostaje kandydata wejściem, a przycisk „Głosuj” wysyła jego id. Rodzic zwiększa głosy i pokazuje, kto prowadzi.
Kryterium sukcesu: głosy zmieniają się w tablicy rodzica; napis o prowadzącym jest zawsze aktualny.
Podpowiedź
U rodzica update z map: lista.map(k => k.id === id ? { ...k, glosy: k.glosy + 1 } : k). Prowadzącego wylicz w computed pętlą for…of.
Komponent app-sterowanie z przyciskami „−” i „+” wysyła zdarzenie zmiana z liczbą -1 albo 1. Rodzic trzyma liczbę biletów (1–6) i sam pilnuje zakresu. Dziecko dostaje też wejścia minOsiagniete i maxOsiagniete (logiczne), które blokują odpowiedni przycisk.
Kryterium sukcesu: rodzic nie pozwala wyjść poza zakres; przyciski blokują się na krańcach; dziecko nie zna liczby biletów.
Podpowiedź
U rodzica [minOsiagniete]="bilety() === 1" i (zmiana)="zmien($event)".
Podziel listę zadań na trzy komponenty: app-nowe-zadanie (pole i przycisk, zdarzenie dodano z tekstem), app-zadanie (jeden wiersz z przyciskami „zrobione” i „usuń”, zdarzenia przelaczono i usunieto) oraz rodzica, który trzyma tablicę.
Kryterium sukcesu: dodawanie, przełączanie (przekreślenie zrobionych) i usuwanie działają, a tablica zadań istnieje wyłącznie u rodzica.
Podpowiedź
Interfejs Zadanie (id, tekst, zrobione) w osobnym pliku. Wiersz dostaje zadanie przez input.required i wysyła tylko id.
Komponent app-pozycja-menu (wejście: obiekt z nazwą i ceną; zdarzenie dodaj) i komponent app-paragon (wejście: tablica zamówionych pozycji; zdarzenie wyczysc). Rodzic trzyma tablicę zamówienia, przekazuje ją paragonowi i obsługuje oba zdarzenia. Paragon pokazuje pozycje i sumę.
Kryterium sukcesu: kliknięcie pozycji menu dopisuje ją do paragonu; „Wyczyść” na paragonie opróżnia zamówienie u rodzica; sumę liczy paragon w computed.
Podpowiedź
Zdarzenie dodaj niech wysyła cały obiekt pozycji (output<Pozycja>()). Paragon nie zmienia tablicy — tylko zgłasza wyczysc.
Co trzeba zapamiętać
readonly x = output<Typ>()deklaruje zdarzenie;this.x.emit(wartosc)je wysyła.- Rodzic nasłuchuje przez
(x)="metoda($event)";$eventto wartość przekazana doemit. - Dane płyną w dół przez
input, zdarzenia w górę przezoutput. - Dane zmienia zawsze ich właściciel: dziecko zgłasza, rodzic decyduje.
- Pomyłka w nazwie zdarzenia u rodzica nie daje błędu — zdarzenie po prostu nie przychodzi.
Sprawdź się: karta wydarzenia ma przyciski „Zapisz się” i „Usuń”. Jakie dwa zdarzenia jej dasz, jakiego typu i co zrobi z nimi rodzic?
Dokumentacja: angular.dev — Custom events with outputs.