28 paź 2026
9 min

Przewodnik po State management w Angularze dla początkujących

Nie każda aplikacja w Angularze korzysta z biblioteki do zarządzania stanem. W wielu projektach w zupełności wystarcza dobrze napisany serwis: w nowszych wersjach Angulara oparty na sygnałach, a w starszych, które sygnałów jeszcze nie mają (sprzed Angulara 16), na BehaviorSubject z RxJS. Są też aplikacje, w których stanem zarządza biblioteka, najczęściej NgRx. Ten artykuł wprowadza w podstawowe rozwiązania, od najprostszych do bardziej rozbudowanych, i pokazuje, kiedy warto sięgnąć po każde z nich.

Stan to po prostu dane, które się zmieniają i które interfejs musi pokazać. To jedyne źródło prawdy w naszej aplikacji: komponenty nie trzymają własnych kopii tych danych, tylko odczytują je ze stanu i pokazują to, co się w nim aktualnie znajduje.

Przykład przewodni: koszyk

Przez cały artykuł rozwijamy jeden przykład: koszyk w małym sklepie. Zaczynamy od najprostszej wersji, w której koszyk obsługuje tylko jeden komponent. W kolejnych sekcjach sklep dostaje nowe funkcje: licznik produktów w nagłówku, kod rabatowy, listę produktów pobieraną z API. Każda z nich sprawia, że dotychczasowe rozwiązanie robi się niewygodne, i wtedy przechodzimy poziom wyżej. Pozycja w koszyku wygląda tak:

interface CartItem {
  id: number;
  name: string;
  price: number;
  qty: number;
}

Dlaczego to ważne? Na początku jeden serwis wstrzykiwany do kilku komponentów w zupełności wystarcza. Problem pojawia się, gdy aplikacja rośnie: do tego samego serwisu trafiają kolejne pola, metody i obsługa API, a różne części aplikacji zmieniają jego dane na własną rękę. Taki serwis staje się nieczytelny, trudno w nim znaleźć błąd, a każda osoba w zespole rozbudowuje go po swojemu.

Naturalną konsekwencją rozbudowy aplikacji jest więc potrzeba store’a, czyli miejsca o jasno określonej strukturze: z jednym stanem, ustalonym sposobem jego odczytu i jedynym dozwolonym sposobem jego zmiany.

Artykuł jest podzielony na poziomy, od najprostszego i najstarszego rozwiązania do najbardziej rozbudowanego i nowoczesnego:

  1. Stan w komponencie
  2. Serwis z BehaviorSubject i prosty serwis jako store
  3. Klasyczny NgRx Store
  4. NgRx SignalStore

Przykłady zakładają Angular 20 lub nowszy, standalone components i nową składnię szablonów (@for, @if).

Poziom 0: stan w komponencie

Jeśli danych potrzebuje tylko jeden komponent, trzymaj je w tym komponencie. Nie ma powodu tworzyć serwisu dla stanu, którego nikt inny nie czyta.

@Component({
  selector: 'app-cart',
  template: `
    <p>Produktów w koszyku: {{ count() }}</p>
    <button (click)="add(product)">Dodaj</button>
  `,
})
export class CartComponent {
  readonly items = signal<CartItem[]>([]);
  readonly count = computed(() => this.items().length);

  add(item: CartItem) {
    this.items.update(items => [...items, item]);
  }
}

signal() przechowuje wartość, a computed() wylicza z niej wartość pochodną i przelicza ją automatycznie, gdy źródło się zmieni. W szablonie sygnał odczytujemy, wywołując go jak funkcję: count().

Moment przejścia: w nagłówku strony ma się pojawić ikonka koszyka z liczbą produktów. Teraz te same dane są potrzebne dwóm komponentom, które nie są ze sobą bezpośrednio powiązane. To niezależne komponenty, leżące w zupełnie innych miejscach struktury plików projektu, np. layout/header/cart-badge i features/cart/cart. Przekazywanie ich przez input() i output() przez kilka poziomów drzewa szybko staje się uciążliwe i jest niepoprawne.

Poziom 1a: serwis z BehaviorSubject

Wspólny stan przenosimy do serwisu, np. src/app/core/services/cart.service.ts, a oba komponenty (layout/header/cart-badge i features/cart/cart) wstrzykują samą jego instancję. Zanim w Angularze pojawiły się sygnały, standardem był do tego BehaviorSubject z RxJS. Wciąż spotkasz go w wielu projektach, więc warto go rozumieć.

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly _items$ = new BehaviorSubject<CartItem[]>([]);

  readonly items$ = this.items$$.asObservable();
  readonly total$ = this.items$.pipe(
    map(items => items.reduce((sum, i) => sum + i.price * i.qty, 0))
  );

  add(item: CartItem) {
    this.items$$.next([...this.items$$.value, item]);
  }
}

Komponent odczytuje dane przez async pipe. Automatycznie subskrybuje on strumień i kończy subskrypcję, gdy komponent zostaje usunięty, więc nie musimy robić tego ręcznie:

@Component({
  selector: 'app-cart-badge',
  imports: [AsyncPipe],
  template: `<span>{{ total$ | async }} zł</span>`,
})
export class CartBadgeComponent {
  readonly total$ = inject(CartService).total$;
}

Co warto zapamiętać z tego wzorca:

  • Dlaczego BehaviorSubject, a nie zwykły Subject? Oba są strumieniami danych, które można subskrybować. Różnią się jednak tym, co dostaje komponent w chwili subskrypcji. BehaviorSubject przechowuje ostatnią wartość i natychmiast ją przekazuje. Co ważne, BehaviorSubject wymaga wartości początkowej (u nas pusta tablica []), więc nawet jeśli jeszcze nie nastąpiła żadna zmiana, komponent od razu ją otrzyma. Subject nie przechowuje ostatniej wartości. Po subskrypcji dostaje się z niego wyłącznie wartości wyemitowane od tego momentu. Dlatego w roli stanu Subject się nie sprawdza: komponent, który pojawi się na ekranie po ostatniej zmianie, nie dostanie aktualnych danych i aż do następnej emisji będzie miał lukę w wyświetlanym stanie. Do przechowywania stanu wybieramy więc BehaviorSubject.
  • Enkapsulacja. Pole _items$ jest prywatne, więc żaden komponent nie może wywołać na nim next() i samodzielnie podmienić danych w koszyku. Na zewnątrz udostępniamy tylko items$, czyli wersję utworzoną przez asObservable(): można ją subskrybować i czytać z niej dane, ale nie da się przez nią niczego zmienić. Jedynym sposobem na zmianę stanu są metody serwisu, takie jak add(). Dzięki temu zawsze wiadomo, gdzie szukać kodu, który modyfikuje koszyk.
  • Przy każdej zmianie tworzymy nową tablicę. Wywołanie push() dopisuje produkt do tej samej tablicy, która już siedzi w BehaviorSubject. Dane w pamięci się zmieniają, ale nie wywołaliśmy next(), więc BehaviorSubject nie wysyła nowej wartości: komponenty o niczym się nie dowiadują, a widok się nie odświeża. Dlatego w metodzie add() budujemy nową tablicę ze starymi elementami i nowym produktem ([…this._items$value, item]) i przekazujemy ją przez next(). Nowa tablica to też nowy obiekt w pamięci, a po tym Angular i operatory RxJS najłatwiej rozpoznają, że coś się zmieniło.

Typowe pułapki to ręczne subscribe() w komponencie bez późniejszego odsubskrybowania (wyciek pamięci) oraz sięganie po .value spoza serwisu, które omija cały mechanizm reaktywności.

Poziom 1b: prosty serwis jako store

Nowe wymaganie: koszyk dostaje kod rabatowy i informację, czy właśnie trwa zapis zamówienia. Moglibyśmy dodać trzy osobne strumienie BehaviorSubject, ale wtedy łatwo o niespójność, na przykład wyczyszczenie pozycji bez wyczyszczenia rabatu.

Do tej pory nasz serwis przechowywał tylko jedną rzecz: listę produktów w koszyku. W prawdziwej aplikacji stan szybko się rozrasta i składa z kilku informacji, które zależą od siebie nawzajem. Na przykład kod rabatowy ma sens tylko wtedy, gdy w koszyku są produkty: po wyczyszczeniu koszyka rabat powinien zniknąć razem z nimi, a przycisk „Zamów” powinien być zablokowany, dopóki trwa zapisywanie zamówienia. Zamiast trzymać każdą z tych informacji (listę produktów, kod rabatowy i informację, loading) w osobnej zmiennej, zbieramy je w jeden obiekt i pozwalamy go zmieniać tylko w jednym miejscu. Serwis nadal jest zwykłym serwisem Angulara, ale działa jak mały store: ma jeden stan, ustalone sposoby jego odczytu i jedną drogę jego zmiany.

interface CartState {
  items: CartItem[];
  discountCode: string | null;
  saving: boolean;
}

const initialState: CartState = {
  items: [],
  discountCode: null,
  saving: false,
};

@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _state$ = new BehaviorSubject<CartState>(initialState);

  readonly items$ = this.select(s => s.items);
  readonly discountCode$ = this.select(s => s.discountCode);
  readonly saving$ = this.select(s => s.saving);
  readonly total$ = this.items$.pipe(
    map(items => items.reduce((sum, i) => sum + i.price * i.qty, 0))
  );

  private select<T>(selector: (state: CartState) => T): Observable<T> {
    return this._state$.pipe(map(selector), distinctUntilChanged());
  }

  private patch(partial: Partial<CartState>) {
    this.state$$.next({ ...this._state$.value, ...partial });
  }

  add(item: CartItem) {
    this.patch({ items: [...this._state$.value.items, item] });
  }

  applyDiscount(code: string) {
    this.patch({ discountCode: code });
  }

  reset() {
    this._state$.next(initialState);
  }
}

W tym kodzie pojawiają się trzy elementy, które są sercem każdej biblioteki do zarządzania stanem:

  • Stan początkowy (initialState): zawsze wiadomo, od czego zaczynamy
  • Selektory (items$, total$): komponenty czytają tylko to, czego potrzebują, a wartości pochodne liczą się w jednym miejscu.
  • Jedna funkcja aktualizująca (patch): każda zmiana przechodzi przez to samo miejsce, więc łatwo ją debugować albo zalogować.

W starszych projektach ten wzorzec znajdziesz pod nazwą „service with a subject”, często z metodami select() i setState().

Moment przejścia: aplikacja rośnie i widzisz, że w kolejnych serwisach piszesz ten sam kod. Flaga saving, obsługa błędu zapisu, cache między ekranami. Kiedy zaczynasz kopiować te same kawałki między serwisami, to dobry sygnał, że czas na reużywalne rozwiązanie, które zamknie ten powtarzalny kod w jednym miejscu.

Poziom 2: klasyczny NgRx Store

Klasyczny NgRx (@ngrx/store) opiera się na wzorcu Redux, spopularyzowanym przez bibliotekę o tej samej nazwie w świecie Reacta. Jego założenie jest proste: cały stan aplikacji leży w jednym miejscu, a zmienić go można tylko w jeden, ściśle określony sposób, opisany niżej. Takie podejście daje najwięcej przewidywalności kosztem największej ilości kodu.

Najważniejsza różnica: komponent nie wywołuje metody, która zmienia stan. Zamiast tego wywołujmy akcję, czyli opis tego, co się wydarzyło. Przepływ wygląda tak:

komponent → dispatch(akcja) → reducer(store) → selektor → komponent

  • Akcja to zwykły obiekt opisujący, co się stało, np. „dodano produkt do koszyka” razem z tym produktem. Komponent wywołuje ją metodą dispatch().
  • Reducer to funkcja, która modyfikuje i przetrzymuje obecny stan aplikacji. Nie robi nic więcej: nie woła API 
  • Selektor to funkcja, która wyciąga ze store’a tylko potrzebny kawałek stanu, np. listę produktów albo sumę koszyka. Odpowiada naszym items$ i total$.

Operacje asynchroniczne, takie jak zapis zamówienia, obsługują osobne efekty: nasłuchują akcji, wołają API i wysyłają kolejną akcję z wynikiem.

W skrócie ten sam koszyk wygląda tak:

export const CartActions = createActionGroup({
  source: 'Cart',
  events: {
    'Item Added': props<{ item: CartItem }>(),
    'Cart Reset': emptyProps(),
  },
});

export const cartReducer = createReducer(
  initialState,
  on(CartActions.itemAdded, (state, { item }) => ({
    ...state,
    items: [...state.items, item],
  })),
  on(CartActions.cartReset, () => initialState)
);

// w komponencie / fasadzie
this.store.dispatch(CartActions.itemAdded({ item }));
readonly items$ = this.store.select(selectCartItems);

Dla małej aplikacji to zwykle za dużo. Jeśli jednak dołączysz do dużego projektu, bardzo możliwe, że spotkasz właśnie ten wzorzec.

Dużym atutem jest debugowanie: NgRx Store współpracuje z Redux DevTools, rozszerzeniem przeglądarki, w którym widać każdą wysłaną akcję oraz stan przed nią i po niej, a nawet można cofnąć aplikację do wcześniejszego stanu. Wystarczy dodać provideStoreDevtools() z pakietu @ngrx/store-devtools.

Poziom 3: NgRx SignalStore

SignalStore to uproszczona wersja NgRx od tych samych twórców, oparta na sygnałach (Angular 16+). Robi to samo co klasyczny Store z poziomu 2, ale bez akcji i rozproszonych plików: stan, selektory (withComputed()) i logika zmian, która w poziomie 2 była rozdzielona między reducery i effecty (withMethods()), są zebrane w jednym miejscu. Dzięki temu bardzo przypomina nasz CartStore z poziomu 1b, tylko zamiast pisać wszystko ręcznie, składasz go z gotowych funkcji biblioteki (withState(), withComputed(), withMethods()).

Ten sam koszyk w SignalStore:

import { computed } from '@angular/core';
import {
  signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';

export const CartStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ items }) => ({
    total: computed(() =>
      items().reduce((sum, i) => sum + i.price * i.qty, 0)
    ),
  })),
  withMethods(store => ({
    add(item: CartItem) {
      patchState(store, { items: [...store.items(), item] });
    },
    applyDiscount(code: string) {
      patchState(store, { discountCode: code });
    },
    reset() {
      patchState(store, initialState);
    },
  }))
);

Tabela zawiera porównania każdego podejścia. Idee są te same, zmieniają się tylko nazwy i miejsce, w którym je zapisujesz:

Do czego służy Prosty serwis  Klasyczny NgRx Store  SignalStore
Przechowanie stanu initialState + BehaviorSubject initialState w createReducer() withState(initialState)
Odczyt wycinków stanu i wartości pochodnych select() i pipe(map(…)) selektory (createSelector()) withComputed() (pola stanu dostępne automatycznie)
Zmiana stanu prywatna metoda patch() reducer (on(…)) patchState() wewnątrz metod
Wywołanie zmiany z komponentu publiczne metody, np. add() akcja wysłana przez dispatch() metody z withMethods()
Operacje asynchroniczne, np. API metody serwisu z HttpClient effecty (createEffect()) metody w withMethods(), np. z rxMethod()

W komponencie korzystasz z niego tak samo jak z serwisu: readonly cart = inject(CartStore) i w szablonie cart.total().

Co zyskujesz w porównaniu z ręcznie pisanym serwisem:

  • Jednolitą strukturę. Każdy store w projekcie wygląda tak samo, więc nowa osoba w zespole od razu wie, gdzie szukać stanu, selektorów i metod.
  • Gotowe rozszerzenia. Na przykład withEntities() z @ngrx/signals/entities do kolekcji z dodawaniem, usuwaniem i aktualizacją po id, albo rxMethod() do metod opartych na RxJS.
  • Narzędzia do debugowania. Społeczność udostępnia gotowe rozszerzenia, np. withDevtools() z pakietu @angular-architects/ngrx-toolkit, który pokazuje zmiany stanu w Redux DevTools, podobnie jak w klasycznym NgRx z poziomu 2.
  • Własne reużywalne rozszerzenia. Powtarzalną logikę, np. wspomniane saving i obsługę błędów, zamykasz w jednym signalStoreFeature() i dodajesz do dowolnego store’a.

Alternatywy

NgRx nie jest jedyną biblioteką do zarządzania stanem w Angularze. Istnieją też inne, a to jedne z popularniejszych: NGXS to podobne podejście do NgRx Store, ale z mniejszą ilością kodu, oparte na klasach i dekoratorach, Elf to lekka biblioteka oparta na RxJS, TanStack Query dla Angulara rozwiązuje wyłącznie problem stanu serwerowego (cache, odświeżanie, mutacje) i bywa lepszym wyborem niż Store, jeśli głównym bólem są dane z API.

Podsumowanie

Zarządzanie stanem w Angularze to drabina, a nie wybór jednej „właściwej” biblioteki. Wiele aplikacji zatrzymuje się na prostym serwisie jako store.

Najważniejsze jest to, czego nauczył nas poziom 1b: stan początkowy, selektory i jedna funkcja aktualizująca. Jeśli piszesz serwisy w ten sposób, przejście na SignalStore zajmie ci mniej niż więcej czasu. Biblioteka niczego wtedy nie zmienia w twoim myśleniu, tylko porządkuje to, co już robisz.

Jeśli zaczynasz nowy projekt, warto rozważyć NgRx SignalStore od samego początku. Dziś dodaje on niemal zerowy narzut kodu w porównaniu z ręcznie pisanym serwisem, więc sprawdzi się nawet w małej aplikacji, a gdy projekt urośnie, nie trzeba będzie niczego przepisywać.

Jeśli natomiast dołączasz do istniejącego projektu, potraktuj ten artykuł jak mapę. Najpierw ustal, na którym poziomie jest rozwiązanie w twoim zespole: serwis z BehaviorSubject, klasyczny NgRx z akcjami i reducerami, czy SignalStore. Potem odszukaj w kodzie trzy rzeczy, które przewijały się przez cały tekst: gdzie trzymany jest stan, skąd komponenty go odczytują i w jaki sposób się go zmienia. Gdy je znajdziesz, reszta rozwiązania przestanie być zagadką, niezależnie od tego, jakiej biblioteki używa projekt.

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.