Serwis i inject() — dane wspólne dla wielu komponentów
Koszyk w sklepie internetowym widać w nagłówku, na liście produktów i na stronie zamówienia. Przepychanie go przez input i output po kilku piętrach komponentów szybko staje się męczące. Dane, które należą do wszystkich, trzymasz w serwisie.
Czego się dziś nauczysz
- Wyjaśnisz, dlaczego dwa komponenty z własnymi sygnałami nie widzą wzajemnie swoich danych.
- Wygenerujesz serwis poleceniem
ng generate servicei umieścisz w nim sygnał oraz metody. - Pobierzesz serwis w komponencie przez
inject()i użyjesz go w szablonie. - Zabezpieczysz dane serwisu: prywatny sygnał,
asReadonly()i metody pilnujące reguł. - Zbudujesz koszyk sklepiku z trzech komponentów i jednego serwisu.
Przygotowanie: lekcje 29–32 — komunikacja rodzic–dziecko przez input, output i model; lekcja 08 — #pole. Przewidywany czas: 45 min z zadaniami.
Dane, które należą do wszystkich
Przez cztery lekcje dane zawsze miały właściciela — rodzica — i płynęły w dół do dzieci. To działa, dopóki komponenty tworzą proste drzewo. Gdy dwa komponenty są rodzeństwem albo leżą daleko od siebie, wygodniej oddać dane trzeciej stronie.
Serwis to zwykła klasa z danymi i metodami, której Angular tworzy jeden egzemplarz na całą aplikację. Każdy komponent, który o niego poprosi, dostaje ten sam egzemplarz — więc wszyscy widzą te same dane.
ng generate service nowa-33-2/zapisy
# CREATE src/app/nowa-33-2/zapisy.spec.ts
# CREATE src/app/nowa-33-2/zapisy.ts
import { Service } from '@angular/core';
@Service()
export class Zapisy {}
| Krok | Kod | Znaczenie |
|---|---|---|
| 1. Klasa serwisu | @Service()export class Zapisy { … } | Angular sam utworzy jeden egzemplarz, gdy ktoś o niego poprosi |
| 2. Komponent prosi o serwis | readonly zapisy = inject(Zapisy); | inject z @angular/core zwraca wspólny egzemplarz |
| 3. Komponent używa serwisu | {{ zapisy.lista().length }}, this.zapisy.dodaj(imie) | zmiana w jednym komponencie od razu widoczna w pozostałych |
@Service() i @Injectable({ providedIn: 'root’ })
Dekorator @Service() pojawił się w Angularze 22 i to jego wstawia ng generate service. W starszych projektach, tutorialach i odpowiedziach na forach zobaczysz @Injectable({ providedIn: 'root' }) — w naszych przykładach oba zapisy działają tak samo: jeden wspólny egzemplarz dla całej aplikacji.
Nigdy new
Nie pisz new Zapisy(). Każde new tworzy osobny egzemplarz z osobnymi danymi — dokładnie problem z przykładu 1. Serwis zawsze bierzesz przez inject, przy deklaracji pola klasy.
Problem: każdy komponent ma swoje dane
Dwa komponenty obok siebie: formularz i lista. Każdy ma własny sygnał zapisani. Zobacz, co się dzieje po dodaniu osoby.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-formularz',
imports: [],
templateUrl: './formularz.html'
})
export class Formularz {
readonly zapisani = signal<string[]>([]);
dodaj(imie: string): void {
if (imie.trim() !== '') {
this.zapisani.update(l => [...l, imie.trim()]);
}
}
}
<div class="card p-3">
<h6>Zapisz się</h6>
<div class="input-group">
<input #pole class="form-control" placeholder="Imię">
<button class="btn btn-primary" (click)="dodaj(pole.value); pole.value = ''">Dodaj</button>
</div>
<p class="small text-secondary mt-2 mb-0">Formularz widzi zapisanych: {{ zapisani().length }}</p>
</div>
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-lista',
imports: [],
templateUrl: './lista.html'
})
export class Lista {
readonly zapisani = signal<string[]>([]);
}
<div class="card p-3">
<h6>Lista zapisanych ({{ zapisani().length }})</h6>
<ul class="mb-0">
@for (z of zapisani(); track $index) { <li>{{ z }}</li> } @empty { <li class="text-secondary">pusto</li> }
</ul>
</div>
import { Component } from '@angular/core';
import { Formularz } from './formularz';
import { Lista } from './lista';
@Component({
selector: 'app-demo',
imports: [Formularz, Lista],
templateUrl: './demo.html'
})
export class Demo {}
<div class="p-3">
<div class="row g-4" style="max-width: 50rem">
<div class="col-md-6"><app-formularz /></div>
<div class="col-md-6"><app-lista /></div>
</div>
</div>
Zapisz się Lista zapisanych (0)
[Imię ][Dodaj] • pusto
Formularz widzi zapisanych: 2
Formularz widzi dwie osoby, a lista jest pusta. Dwa komponenty mają dwa różne sygnały o tej samej nazwie — jak dwóch uczniów z zeszytem podpisanym „Matematyka”, ale z innymi notatkami.
Zatrzymaj się
Te komponenty są rodzeństwem. Jak rozwiązałbyś to przez input i output i co musiałby dopisać rodzic Demo?
Rozwiązanie: wspólny serwis
Dane przenosimy do serwisu Zapisy. Oba komponenty pobierają go przez inject() i nie mają już własnej listy.
import { Service, signal } from '@angular/core';
@Service()
export class Zapisy {
readonly lista = signal<string[]>([]);
dodaj(imie: string): void {
if (imie.trim() !== '') {
this.lista.update(l => [...l, imie.trim()]);
}
}
}
import { Component, inject } from '@angular/core';
import { Zapisy } from './zapisy';
@Component({
selector: 'app-formularz',
imports: [],
templateUrl: './formularz.html'
})
export class Formularz {
readonly zapisy = inject(Zapisy);
readonly zapisani = this.zapisy.lista;
dodaj(imie: string): void {
this.zapisy.dodaj(imie);
}
}
<div class="card p-3">
<h6>Zapisz się <span class="badge text-bg-info">serwis</span></h6>
<div class="input-group">
<input #pole class="form-control" placeholder="Imię">
<button class="btn btn-primary" (click)="dodaj(pole.value); pole.value = ''">Dodaj</button>
</div>
<p class="small text-secondary mt-2 mb-0">Formularz widzi zapisanych: {{ zapisani().length }}</p>
</div>
import { Component, inject } from '@angular/core';
import { Zapisy } from './zapisy';
@Component({
selector: 'app-lista',
imports: [],
templateUrl: './lista.html'
})
export class Lista {
readonly zapisy = inject(Zapisy);
}
<div class="card p-3">
<h6>Lista zapisanych ({{ zapisy.lista().length }}) <span class="badge text-bg-info">serwis</span></h6>
<ul class="mb-0">
@for (z of zapisy.lista(); track $index) { <li>{{ z }}</li> } @empty { <li class="text-secondary">pusto</li> }
</ul>
</div>
import { Component } from '@angular/core';
import { Formularz } from './formularz';
import { Lista } from './lista';
@Component({
selector: 'app-demo',
imports: [Formularz, Lista],
templateUrl: './demo.html'
})
export class Demo {}
<div class="p-3">
<h5>Wersja z serwisem</h5>
<div class="row g-4" style="max-width: 50rem">
<div class="col-md-6"><app-formularz /></div>
<div class="col-md-6"><app-lista /></div>
</div>
</div>
Zapisz się [serwis] Lista zapisanych (2) [serwis]
[Imię ][Dodaj] • Ala
Formularz widzi zapisanych: 2 • Ola
Teraz każde dodanie od razu pojawia się na liście, a liczby się zgadzają. Rodzic Demo nadal jest pusty — nie przekazuje niczego, bo nie musi. W formularzu zapisani = this.zapisy.lista to nie kopia danych, tylko druga nazwa tego samego sygnału — dzięki temu szablon formularza prawie się nie zmienił. Plik zapisy.spec.ts (testy) możesz usunąć; w laboratorium go nie ma.
Zatrzymaj się
Zamień w liście inject(Zapisy) na new Zapisy(). Co się stanie i dlaczego?
Serwis pilnuje swoich danych
W przykładzie 2 każdy komponent mógł zrobić zapisy.lista.set([]) i wyczyścić listę za plecami serwisu. Tu serwis trzyma sygnał jako private, a na zewnątrz udostępnia go tylko do odczytu przez asReadonly(). Dochodzą computed z limitem miejsc i trzeci komponent — nagłówek.
import { Service, computed, signal } from '@angular/core';
@Service()
export class Zapisy {
readonly limit = 4;
private readonly _lista = signal<string[]>([]);
readonly lista = this._lista.asReadonly();
readonly wolne = computed(() => this.limit - this._lista().length);
dodaj(imie: string): void {
if (imie.trim() === '' || this.wolne() === 0) {
return;
}
this._lista.update(l => [...l, imie.trim()]);
}
usun(nr: number): void {
this._lista.update(l => l.filter((z, i) => i !== nr));
}
}
import { Component, inject } from '@angular/core';
import { Zapisy } from './zapisy';
@Component({
selector: 'app-naglowek',
imports: [],
templateUrl: './naglowek.html'
})
export class Naglowek {
readonly zapisy = inject(Zapisy);
}
<div class="alert mb-3" [class.alert-success]="zapisy.wolne() > 0" [class.alert-danger]="zapisy.wolne() === 0">
Warsztat druku 3D — wolnych miejsc: <strong>{{ zapisy.wolne() }}</strong> z {{ zapisy.limit }}
</div>
import { Component, inject } from '@angular/core';
import { Zapisy } from './zapisy';
@Component({
selector: 'app-formularz',
imports: [],
templateUrl: './formularz.html'
})
export class Formularz {
readonly zapisy = inject(Zapisy);
}
<div class="card p-3">
<h6>Zapisz się</h6>
<div class="input-group">
<input #pole class="form-control" placeholder="Imię" [disabled]="zapisy.wolne() === 0">
<button class="btn btn-primary" [disabled]="zapisy.wolne() === 0"
(click)="zapisy.dodaj(pole.value); pole.value = ''">Dodaj</button>
</div>
</div>
import { Component, inject } from '@angular/core';
import { Zapisy } from './zapisy';
@Component({
selector: 'app-lista',
imports: [],
templateUrl: './lista.html'
})
export class Lista {
readonly zapisy = inject(Zapisy);
}
<ul class="list-group">
@for (z of zapisy.lista(); track $index; let nr = $index) {
<li class="list-group-item d-flex justify-content-between">
{{ z }}
<button class="btn btn-sm btn-outline-danger" (click)="zapisy.usun(nr)">Usuń</button>
</li>
} @empty {
<li class="list-group-item text-secondary">Nikt się jeszcze nie zapisał.</li>
}
</ul>
import { Component } from '@angular/core';
import { Formularz } from './formularz';
import { Lista } from './lista';
import { Naglowek } from './naglowek';
@Component({
selector: 'app-demo',
imports: [Naglowek, Formularz, Lista],
templateUrl: './demo.html'
})
export class Demo {}
<div class="p-3">
<div style="max-width: 50rem">
<app-naglowek />
<div class="row g-4">
<div class="col-md-6"><app-formularz /></div>
<div class="col-md-6"><app-lista /></div>
</div>
</div>
</div>
Warsztat druku 3D — wolnych miejsc: 0 z 4 (czerwony pasek)
[Imię (zablokowane)][Dodaj (zablokowany)]
A [Usuń] B [Usuń] C [Usuń] D [Usuń]
Po czwartej osobie nagłówek robi się czerwony, a pole i przycisk w formularzu są zablokowane. Usuń kogoś — wszystkie trzy komponenty od razu to widzą. Podkreślnik w _lista to tylko zwyczaj: „prywatna wersja pola lista”.
Spróbuj zepsuć
W komponencie listy napisz w metodzie this.zapisy.lista.set([]) — TS2339: Property 'set’ does not exist on type 'Signal<string[]>’. A this.zapisy._lista — TS2341: Property '_lista’ is private and only accessible within class 'Zapisy’. Jedyna droga zmiany danych to metody dodaj i usun, które pilnują limitu.
Zatrzymaj się
Limit sprawdzamy w serwisie (dodaj) i w formularzu ([disabled]). Które zabezpieczenie jest ważniejsze i dlaczego?
Koszyk w sklepiku szkolnym
Trzy miejsca, jeden serwis: lista produktów z przyciskami „Do koszyka”, widok koszyka z usuwaniem i pasek z sumą u rodzica. To układ, który zobaczysz w niemal każdym sklepie internetowym.
import { Service, computed, signal } from '@angular/core';
export interface Pozycja {
nazwa: string;
cena: number;
ile: number;
}
@Service()
export class Koszyk {
private readonly _pozycje = signal<Pozycja[]>([]);
readonly pozycje = this._pozycje.asReadonly();
readonly suma = computed(() => {
let s = 0;
for (const p of this._pozycje()) {
s += p.cena * p.ile;
}
return s;
});
readonly sztuk = computed(() => {
let n = 0;
for (const p of this._pozycje()) {
n += p.ile;
}
return n;
});
dodaj(nazwa: string, cena: number): void {
const jest = this._pozycje().find(p => p.nazwa === nazwa);
if (jest) {
this._pozycje.update(l => l.map(p => (p.nazwa === nazwa ? { ...p, ile: p.ile + 1 } : p)));
} else {
this._pozycje.update(l => [...l, { nazwa, cena, ile: 1 }]);
}
}
usun(nazwa: string): void {
this._pozycje.update(l => l.filter(p => p.nazwa !== nazwa));
}
}
import { Component, inject } from '@angular/core';
import { Koszyk } from './koszyk';
@Component({
selector: 'app-produkty',
imports: [],
templateUrl: './produkty.html'
})
export class Produkty {
readonly koszyk = inject(Koszyk);
readonly oferta = [
{ nazwa: 'Drożdżówka', cena: 3.5 },
{ nazwa: 'Woda', cena: 2 },
{ nazwa: 'Kanapka', cena: 7 },
{ nazwa: 'Sok', cena: 4.5 },
];
}
<h6>Oferta</h6>
<ul class="list-group">
@for (p of oferta; track p.nazwa) {
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ p.nazwa }} — {{ p.cena.toFixed(2) }} zł
<button class="btn btn-sm btn-primary" (click)="koszyk.dodaj(p.nazwa, p.cena)">Do koszyka</button>
</li>
}
</ul>
import { Component, inject } from '@angular/core';
import { Koszyk } from './koszyk';
@Component({
selector: 'app-widok-koszyka',
imports: [],
templateUrl: './widok-koszyka.html'
})
export class WidokKoszyka {
readonly koszyk = inject(Koszyk);
}
<h6>Koszyk</h6>
<ul class="list-group">
@for (p of koszyk.pozycje(); track p.nazwa) {
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ p.nazwa }} × {{ p.ile }}
<button class="btn btn-sm btn-outline-danger" (click)="koszyk.usun(p.nazwa)">✕</button>
</li>
} @empty {
<li class="list-group-item text-secondary">Koszyk jest pusty.</li>
}
</ul>
import { Component, inject } from '@angular/core';
import { Koszyk } from './koszyk';
import { Produkty } from './produkty';
import { WidokKoszyka } from './widok-koszyka';
@Component({
selector: 'app-demo',
imports: [Produkty, WidokKoszyka],
templateUrl: './demo.html'
})
export class Demo {
readonly koszyk = inject(Koszyk);
}
<div class="p-3">
<div style="max-width: 56rem">
<div class="text-bg-dark rounded p-2 mb-3 d-flex justify-content-between">
<span>Sklepik ZSMI</span>
<span>🛒 {{ koszyk.sztuk() }} szt. · {{ koszyk.suma().toFixed(2) }} zł</span>
</div>
<div class="row g-4">
<div class="col-md-7"><app-produkty /></div>
<div class="col-md-5"><app-widok-koszyka /></div>
</div>
</div>
</div>
Sklepik ZSMI 🛒 3 szt. · 14.00 zł
Koszyk: Drożdżówka × 2 [✕]
Kanapka × 1 [✕]
(po usunięciu drożdżówek: 🛒 1 szt. · 7.00 zł)
dodaj najpierw szuka produktu w koszyku (find). Jeśli jest — zwiększa jego ile przez map z kopią obiektu; jeśli nie — dopisuje nową pozycję. suma i sztuk to computed z pętlą for…of. Interfejs Pozycja leży w pliku serwisu, bo używa go tylko serwis i jego komponenty.
Krótszy zapis sumy
W cudzym kodzie zamiast pętli zobaczysz często this._pozycje().reduce((s, p) => s + p.ile, 0) — to ta sama pętla zapisana w jednej linii: zacznij od 0 i dodawaj kolejne p.ile.
Zatrzymaj się
Pasek z sumą jest w szablonie rodzica Demo. Czy rodzic przekazuje cokolwiek do app-produkty albo app-widok-koszyka? Skąd pasek wie o zmianach?
Zestawienie elementów
| Element | Znaczenie |
|---|---|
ng generate service folder/nazwa | tworzy plik serwisu (i plik testów .spec.ts) |
@Service() | klasa jest serwisem; jeden egzemplarz na całą aplikację (Angular 22) |
@Injectable({ providedIn: 'root' }) | starszy zapis o tym samym znaczeniu |
readonly x = inject(Serwis) | pobranie wspólnego egzemplarza — przy deklaracji pola |
private readonly _lista = signal(…) | sygnał widoczny tylko wewnątrz serwisu |
readonly lista = this._lista.asReadonly() | ten sam sygnał udostępniony tylko do odczytu (bez set, update) |
computed w serwisie | wartości wyliczane widoczne dla wszystkich komponentów |
lista.find(p => …) | pierwszy pasujący element albo undefined |
Zanim utkniesz
| Zapis | Problem |
|---|---|
new Zapisy() zamiast inject(Zapisy) | Brak błędu, ale każdy komponent ma własny egzemplarz z osobnymi danymi — problem z przykładu 1. |
Klasa serwisu bez @Service() | Strona pusta, w konsoli przeglądarki: NG0201: No provider found for `Zapisy`. Angular nie wie, że ma utworzyć egzemplarz. |
inject(Zapisy) wewnątrz metody, np. dodaj() | Po kliknięciu w konsoli: NG0203: … inject() function must be called from an injection context …. Wywołuj inject przy deklaracji pola klasy. |
this.zapisy.lista.set([]) przy asReadonly() | TS2339: Property 'set’ does not exist on type 'Signal<string[]>’. Zmieniaj dane metodami serwisu. |
{{ zapisy.lista.length }} | Ostrzeżenie NG8109 lista is a function and should be invoked: lista(). Sygnał z serwisu też czytasz z nawiasami. |
Komponenty zmieniają dane serwisu bezpośrednio przez set | Działa, ale omija reguły serwisu (np. limit miejsc). Prywatny sygnał + asReadonly() + metody. |
Zadania
Serwis Licznik z sygnałem wartosc i metodami plus oraz reset. Dwa komponenty: jeden z przyciskiem „+1”, drugi z wyświetlaczem i przyciskiem „Reset”.
Kryterium sukcesu: kliknięcie w pierwszym komponencie zmienia liczbę w drugim i odwrotnie; rodzic nie ma żadnych pól.
Podpowiedź
Przykład 2 z mniejszą liczbą danych. Serwis wygeneruj poleceniem ng g s.
Serwis Motyw z sygnałem ciemny (true/false) i metodą przelacz(). Komponent nagłówka ma przycisk przełączania, a dwa inne komponenty (karta i stopka) zmieniają wygląd na ciemny, gdy motyw jest włączony.
Kryterium sukcesu: jeden przycisk zmienia wygląd trzech komponentów; żaden z nich nie ma input ani output.
Podpowiedź
W każdym komponencie readonly motyw = inject(Motyw) i [class.text-bg-dark]="motyw.ciemny()".
Serwis Powiadomienia z prywatną listą komunikatów udostępnioną przez asReadonly() i metodami pokaz(tekst) oraz zamknij(nr). Komponent app-panel-powiadomien wyświetla komunikaty jako alert z przyciskiem zamknięcia. Dwa inne komponenty (np. „Zapis na warsztat” i „Zgłoszenie usterki”) wywołują pokaz po kliknięciu swoich przycisków.
Kryterium sukcesu: komunikaty z obu komponentów trafiają do jednego panelu; zamknięcie usuwa właściwy komunikat; spoza serwisu nie da się wywołać set na liście.
Podpowiedź
zamknij jak usun z przykładu 3 — filter po indeksie.
Serwis Ulubione z listą tytułów i metodą przelacz(tytul) (dodaje albo usuwa). Komponent app-karta (wejście tytul) ma przycisk ☆/★ korzystający z serwisu. Nagłówek pokazuje „Ulubione: 2” i listę tytułów.
Kryterium sukcesu: gwiazdka na karcie odpowiada stanowi serwisu; nagłówek zmienia się po każdym kliknięciu na dowolnej karcie.
Podpowiedź
W serwisie metoda czyUlubione(tytul) zwracająca this._lista().includes(tytul) — wywołana w szablonie karty odświeża się razem z sygnałem.
Serwis z listą książek (id, tytul, autor, wypozyczona) udostępnianą przez asReadonly() i metodami wypozycz(id) oraz zwroc(id). Trzy komponenty: katalog (wszystkie książki, przycisk przy dostępnych), „moje wypożyczenia” (z przyciskiem zwrotu) i licznik w nagłówku („dostępnych: X / Y”).
Kryterium sukcesu: wypożyczona książka znika z dostępnych w katalogu i pojawia się w wypożyczeniach; licznik zawsze się zgadza.
Podpowiedź
W serwisie dwa computed: dostepne i wypozyczone (filter). Zmiana jednego pola książki — map z kopią obiektu, jak w lekcji 31.
Rozbuduj przykład 4: produkty mają pole stan (liczba sztuk w magazynie) przechowywane w serwisie. „Do koszyka” jest zablokowany, gdy w koszyku jest już tyle sztuk, ile w magazynie. Dodaj komponent app-kasa z polem „Kwota od klienta” ([(ngModel)]) i wyliczoną resztą oraz przyciskiem „Zapłać”, który czyści koszyk tylko wtedy, gdy kwota wystarcza.
Kryterium sukcesu: nie da się dodać więcej sztuk niż w magazynie; reszta liczy się na bieżąco; „Zapłać” przy za małej kwocie nic nie robi.
Podpowiedź
W serwisie metoda ileWKoszyku(nazwa) i wyczysc(). Kwota od klienta to sygnał w komponencie kasy, a reszta — computed z koszyk.suma().
Co trzeba zapamiętać
- Serwis to klasa z
@Service()(dawniej@Injectable({ providedIn: 'root' })); Angular tworzy jeden egzemplarz na całą aplikację. - Komponent pobiera serwis przez
readonly x = inject(Serwis)przy deklaracji pola — nigdy przeznew. - Sygnał w serwisie widzą wszystkie komponenty; zmiana w jednym od razu widać w pozostałych.
- Dane chronisz prywatnym sygnałem,
asReadonly()i metodami, które pilnują reguł. input/output— dla rodzica i dziecka; serwis — dla danych wspólnych dla wielu, odległych komponentów.
Sprawdź się: kiedy przekażesz dane przez input/output, a kiedy przez serwis? Podaj po jednym przykładzie z aplikacji szkolnej.
Dokumentacja: angular.dev — Creating and using services.