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

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.

Angular 22 output() emit() $event dane w dół i zdarzenia w górę 45 min
CEL LEKCJI

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.

TEORIA

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

KrokKodGdzie
1. Dziecko deklaruje zdarzeniereadonly zapisz = output<void>();karta.ts; void — zdarzenie bez danych
2. Dziecko wysyła zdarzeniethis.zapisz.emit();w metodzie wywołanej z (click)
2a. …z danymithis.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.

PRZYKŁAD 1

Zdarzenie bez danych

Karta ma przycisk „Zapisz się”, który wysyła zdarzenie zapisz. Rodzic liczy wszystkie zapisy ze wszystkich kart.

src/app/nowa-31-1/karta.ts — pełny plik
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();
    }
}
src/app/nowa-31-1/karta.html — pełny plik
<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>
src/app/nowa-31-1/demo.ts — pełny plik
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);
    }
}
src/app/nowa-31-1/demo.html — pełny plik
<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>
wynik po trzech kliknięciach (na różnych kartach)
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.)

PRZYKŁAD 2

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.

src/app/nowa-31-2/karta.ts — pełny plik
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());
    }
}
src/app/nowa-31-2/karta.html — pełny plik
<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>
src/app/nowa-31-2/demo.ts — pełny plik
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');
}
src/app/nowa-31-2/demo.html — pełny plik
<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>
wynik po kliknięciu „Wybierz” na trzeciej karcie
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ą?

PRZYKŁAD 3

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.

src/app/nowa-31-3/wydarzenie.ts — pełny plik
export interface Wydarzenie {
    id: number;
    tytul: string;
    sala: string;
}
src/app/nowa-31-3/karta.ts — pełny plik
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>();
}
src/app/nowa-31-3/karta.html — pełny plik
<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>
src/app/nowa-31-3/demo.ts — pełny plik
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));
    }
}
src/app/nowa-31-3/demo.html — pełny plik
<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>
wynik po odwołaniu środkowej karty
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?

PRZYKŁAD 4

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.

src/app/nowa-31-4/formularz.ts — pełny plik
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('');
    }
}
src/app/nowa-31-4/formularz.html — pełny plik
<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>
src/app/nowa-31-4/demo.ts — pełny plik
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++;
    }
}
src/app/nowa-31-4/demo.html — pełny plik
<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>
wynik po wpisaniu „Gra miejska”, „5” i Enter
#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?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenie
output z @angular/corefunkcja 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
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
U rodzica (zapisano), w dziecku zdarzenie zapiszBrak 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 rodzicaNG8001: ’app-karta’ is not a known element (lekcja 04).
Dziecko próbuje samo usunąć element z listy rodzicaNie da się — dziecko nie ma dostępu do tablicy rodzica. I bardzo dobrze: dziecko zgłasza, rodzic decyduje.
ZADANIA

Zadania

ZAD 1Polub★☆☆

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.

ZAD 2Wybór koloru★☆☆

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

ZAD 3Głosowanie★★☆

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.

ZAD 4Licznik z limitem★★☆

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

ZAD 5Lista zadań z komponentów★★★

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.

ZAD 6Zamówienie w barze★★★

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.

PODSUMOWANIE

Co trzeba zapamiętać

  • readonly x = output<Typ>() deklaruje zdarzenie; this.x.emit(wartosc) je wysyła.
  • Rodzic nasłuchuje przez (x)="metoda($event)"; $event to wartość przekazana do emit.
  • Dane płyną w dół przez input, zdarzenia w górę przez output.
  • 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.

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