Retrieve Slice: Select by ID ohne Detail-Komponentenlogik
Eine Detailseite wird häufig über eine Route geöffnet.
Das klassische Muster ist bekannt:
URL /articles/:id → Detail-Komponente liest Route Param → Komponente triggert loadById(id) → Store oder Service lädt Daten → Detail-Template rendertDas ist nicht falsch.
Im Gegenteil.
Dieser Flow hat starke Vorteile:
- Deeplinks funktionieren.
- Browser Reload funktioniert.
- externe Links funktionieren.
- die URL beschreibt den Einstiegspunkt.
Das sind echte Argumente.
Trotzdem zeige ich in diesem Artikel bewusst einen anderen Schnitt.
Nicht, weil Route Params schlecht wären.
Sondern weil ich einen anderen Punkt zeigen möchte:
Denkt mehr reaktiv. Denkt in Ableitungen. Haltet Komponenten flach.
Wenn ein Benutzer aus einer bereits geladenen Liste heraus ein Element auswählt, ist diese Auswahl ein fachlicher Zustand.
Die Detailseite muss dann nicht selbst im Init anfangen, eine ID aus der Route zu lesen, daraus einen Load abzuleiten und anschließend ein ViewModel zu bauen.
Sie kann einfach eine Projektion rendern:
articles + selectedArticleId → selectedArticle → selectedArticleVm → Detail TemplateDas ist der Fokus dieses Artikels.
Der Schnitt
Abschnitt betitelt „Der Schnitt“
Der wichtigste Punkt:
Die Detail-Komponente lädt nicht.
Sie liest keine Route Param ID.
Sie triggert kein loadById().
Sie entscheidet nicht, wie ein Artikel gefunden wird.
Sie rendert nur ein ViewModel.
Die Auswahl entsteht vorher:
List Item Click → selectArticle(articleId) → articleSelected({ articleId }) → selectedArticleId → selectedArticleVmDie Navigation ist eine Reaktion auf dieselbe Auswahl.
Nicht die Quelle der Auswahl.
Die Gegenposition ist nicht falsch
Abschnitt betitelt „Die Gegenposition ist nicht falsch“Der klassische Route-Param-Flow ist nicht schlecht.
Er löst ein echtes Problem:
Wie kommt ein Benutzer direkt auf eine Detailseite?
Wenn die Antwort lautet: „Über eine URL“, dann muss die URL genug Information enthalten, um diesen Zustand wiederherzustellen.
Das spricht für den klassischen Flow:
Route Param → loadById → Detail ViewModelDer Preis ist aber, dass die Detailseite oft wieder mehr Verantwortung bekommt.
Sie wird leicht zum Ort, an dem Route Param, Load, Selection, Fehlerzustand und ViewModel-Aufbau zusammenlaufen.
Der hier gezeigte Flow zahlt einen anderen Preis.
Er macht die In-App-Auswahl sehr sauber reaktiv.
Dafür braucht direkter Einstieg über URL eine zusätzliche Fallback-Strategie.
Keiner dieser Ansätze ist grundsätzlich falsch.
Sie optimieren auf unterschiedliche Belastungen.
Route Param Flow → optimiert auf Wiederherstellbarkeit über URL
Selection Flow → optimiert auf reaktive Ableitung im laufenden UI-ZustandMan sollte nur wissen, wofür man sich entscheidet.
Was dieser Artikel bewusst nicht zeigt
Abschnitt betitelt „Was dieser Artikel bewusst nicht zeigt“Dieser Artikel zeigt keinen vollständigen Deeplink-Fallback.
Also nicht:
Direkter Einstieg auf /articles/foo → Route Param lesen → Artikel laden → selectedArticleId setzen → Detail rendernDas wäre fachlich korrekt.
Aber es wäre ein anderer Slice.
Man könnte dafür verschiedene Strategien nutzen:
- Route Param als Fallback lesen
- Detail-Retrieve-Slice für direkten Einstieg
- Resolver
- initiales
loadRequested - Rehydration aus URL
byId-Collection mitselectedId- separaten Detail Store
Alles valide.
Aber nicht der Fokus dieses Artikels.
Hier geht es um den In-App-Flow:
Liste ist geladen. Benutzer wählt ein Element. Detail ist eine Ableitung dieser Auswahl.
Zielstruktur
Abschnitt betitelt „Zielstruktur“Eine mögliche Struktur für diesen Slice:
article/├── entities/│ └── article.model.ts├── infrastructure/│ ├── article.dto.ts│ ├── article.mapper.ts│ └── article.resource.ts├── +state/│ ├── article.store.ts│ ├── article-list.vm.ts│ ├── article-detail.vm.ts│ ├── article-list-view-model.mapper.ts│ ├── article-detail-view-model.mapper.ts│ ├── article-selection.events.ts│ └── article-navigation-intent.events.ts├── application/│ ├── article-list.facade.ts│ └── article-detail.facade.ts└── presentation/ ├── article-list-page.component.ts ├── article-list-page.component.html ├── article-detail-page.component.ts └── article-detail-page.component.htmlDie Struktur ist bewusst ähnlich zum Retrieve-Slice.
Der Unterschied ist die Selection:
ArticleResource → kapselt private httpResource → stellt articles, isLoading, error und reload bereit
ArticleStore → hält selectedArticleId → orchestriert articles und Selection → leitet listVm und selectedArticleVm abDie Detail-Komponente bekommt nicht die ID und sucht selbst.
Sie bekommt ein ViewModel.
Infrastructure-Grenze: Resource bleibt gekapselt
Abschnitt betitelt „Infrastructure-Grenze: Resource bleibt gekapselt“Der Slice baut auf derselben Grenze wie der Retrieve-Artikel auf.
Die konkrete Angular Resource bleibt vollständig in der Infrastructure.
import { computed, Injectable } from '@angular/core';import { httpResource } from '@angular/common/http';
import { Article } from '../entities/article.model';import { mapArticleResponse } from './article.mapper';
@Injectable()export class ArticleResource { private readonly resource = httpResource<readonly Article[]>( () => ({ url: 'https://lorem-api.com/api/article', method: 'GET', }), { parse: mapArticleResponse, }, );
readonly articles = computed<readonly Article[] | null>(() => { if (!this.resource.hasValue()) { return null; }
return this.resource.value(); });
readonly isLoading = this.resource.isLoading; readonly error = this.resource.error;
reload(): void { this.resource.reload(); }}hasValue() schützt hier den technischen Zugriff auf value().
Das ist Framework-Semantik der Angular Resource.
Sie gehört an die Stelle, an der die Resource erzeugt wird.
Der Store kennt danach nur noch die anwendungsnahe API:
articles: Signal<readonly Article[] | null>isLoading: Signal<boolean>error: Signal<Error | undefined>reload(): voidDamit unterscheiden wir zwei Arten von Guards:
technischer Resource-Guard → kann die Angular Resource sicher gelesen werden? → Infrastructure
Projektions-Guard → gibt es eine Source, eine Auswahl und einen passenden Artikel? → pure ViewModel-ProjektionBeide Guards sind sinnvoll.
Sie beantworten nur unterschiedliche Fragen.
Der Store soll keine davon als sichtbare Falllogik ausformulieren.
Er orchestriert benannte Source Signals, Selection-State und Projektoren.
1. Selection Event: Auswahl als fachlicher Intent
Abschnitt betitelt „1. Selection Event: Auswahl als fachlicher Intent“Der Benutzer klickt auf ein Listenelement.
Das ist mehr als ein UI-Klick.
Es ist eine fachliche Auswahl.
import { eventGroup, type } from '@ngrx/signals/events';
export const articleSelectionEvents = eventGroup({ source: 'Article Selection', events: { articleSelected: type<{ readonly articleId: string }>(), },});articleSelected bedeutet nicht:
Navigiere jetzt.
Und auch nicht:
Lade jetzt.
Es bedeutet nur:
Dieser Artikel wurde ausgewählt.
Was daraus folgt, sind getrennte Reaktionen.
2. Navigation Intent: Routing als Reaktion
Abschnitt betitelt „2. Navigation Intent: Routing als Reaktion“Wenn ein Artikel ausgewählt wurde, soll die Anwendung zur Detailroute wechseln.
Aber auch das beschreiben wir als Intent.
import { eventGroup, type } from '@ngrx/signals/events';
export const articleNavigationIntentEvents = eventGroup({ source: 'Article Navigation Intent', events: { openDetail: type<{ readonly articleId: string }>(), },});Damit bleibt die Richtung klar:
articleSelected → openDetail → Router navigiertDer Store selbst muss nicht den Router kennen.
Die Listen-Komponente auch nicht.
3. ViewModels: Liste und Detail sind nicht dasselbe
Abschnitt betitelt „3. ViewModels: Liste und Detail sind nicht dasselbe“Ein Listen-Item braucht andere Daten als eine Detailseite.
Deshalb trenne ich die ViewModels.
article-list.vm.ts
Abschnitt betitelt „article-list.vm.ts“export interface ArticleListItemVm { readonly id: string; readonly title: string; readonly subtitle: string;}article-detail.vm.ts
Abschnitt betitelt „article-detail.vm.ts“export interface ArticleDetailVm { readonly id: string; readonly title: string; readonly subtitle: string; readonly heroImageUrl: string; readonly paragraphs: readonly string[];}Das ist kein Selbstzweck.
Die Liste soll nicht aus Versehen Detailwissen bekommen.
Die Detailseite soll nicht aus Versehen mit einem Listenmodell arbeiten.
Beide Views sind Projektionen derselben Entity.
Aber sie beantworten unterschiedliche UI-Fragen.
4. Mapper: Source und Selection zu ViewModels
Abschnitt betitelt „4. Mapper: Source und Selection zu ViewModels“Die Mapper bleiben klein.
Sie übernehmen aber jetzt auch die Projektions-Guards, die für den UI-Vertrag relevant sind.
Der Store muss dadurch keine Conditions über Source-Verfügbarkeit, Selection oder Treffer enthalten.
article-list-view-model.mapper.ts
Abschnitt betitelt „article-list-view-model.mapper.ts“import { Article } from '../entities/article.model';import { ArticleListItemVm } from './article-list.vm';
export const toArticleListViewModel = (articles: readonly Article[] | null): readonly ArticleListItemVm[] | null => { if (articles === null) { return null; }
return articles.map(toArticleListItemViewModel);};
const toArticleListItemViewModel = ({ slug, title, subtitle }: Article): ArticleListItemVm => ({ id: slug, title, subtitle,});null bedeutet hier:
Die Source liefert aktuell noch keine lesbare Artikelliste.
Ein erfolgreich geladenes leeres Array bleibt dagegen ein gültiges Listen-ViewModel:
null → Source aktuell nicht verfügbar
[] → Source verfügbar, aber keine Artikel vorhandenarticle-detail-view-model.mapper.ts
Abschnitt betitelt „article-detail-view-model.mapper.ts“import { Article } from '../entities/article.model';import { ArticleDetailVm } from './article-detail.vm';
interface SelectedArticleViewModelInput { readonly articles: readonly Article[] | null; readonly selectedArticleId: string | null;}
export const toSelectedArticleViewModel = ({ articles, selectedArticleId }: SelectedArticleViewModelInput): ArticleDetailVm | null => { if (articles === null || selectedArticleId === null) { return null; }
const selectedArticle = articles.find(({ slug }) => slug === selectedArticleId) ?? null;
if (selectedArticle === null) { return null; }
return toArticleDetailViewModel(selectedArticle);};
const toArticleDetailViewModel = ({ slug, title, subtitle, heroImageUrl, paragraphs }: Article): ArticleDetailVm => ({ id: slug, title, subtitle, heroImageUrl, paragraphs,});Diese Conditions sind keine technischen Resource-Guards.
Sie beschreiben die Gültigkeit der Detail-Projektion:
keine Source → kein Detail-ViewModel
keine Selection → kein Detail-ViewModel
Selection findet keinen Artikel → kein Detail-ViewModel
Source und passende Selection vorhanden → ArticleDetailVmWichtig ist die Richtung:
articles → ArticleListItemVm[]
articles + selectedArticleId → ArticleDetailVm | nullNicht:
Template → article.title → article.content.split(...) → if author existsDie Templates sollen rendern.
Nicht suchen, absichern oder übersetzen.
5. Store: Selection und Ableitungen orchestrieren
Abschnitt betitelt „5. Store: Selection und Ableitungen orchestrieren“Der Store konsumiert das sichere articles-Signal des Infrastructure-Adapters.
Zusätzlich hält er die aktuelle Auswahl.
Er kennt keine HttpResourceRef.
Er kennt kein hasValue().
Und er entscheidet nicht selbst, wann aus Source und Selection ein gültiges Detail-ViewModel entsteht.
import { computed, inject } from '@angular/core';import { patchState, signalStore, withComputed, withProps, withState } from '@ngrx/signals';import { Events, withEventHandlers } from '@ngrx/signals/events';import { map, tap } from 'rxjs';
import { ArticleResource } from '../infrastructure/article.resource';import { articleNavigationIntentEvents } from './article-navigation-intent.events';import { articleSelectionEvents } from './article-selection.events';import { toSelectedArticleViewModel } from './article-detail-view-model.mapper';import { toArticleListViewModel } from './article-list-view-model.mapper';
interface ArticleState { readonly selectedArticleId: string | null;}
const initialState: ArticleState = { selectedArticleId: null,};
export const ArticleStore = signalStore( withState(initialState),
withProps(() => ({ _articleResource: inject(ArticleResource), })),
withComputed(({ _articleResource, selectedArticleId }) => ({ isLoading: _articleResource.isLoading, error: _articleResource.error,
listVm: computed(() => toArticleListViewModel(_articleResource.articles())),
selectedArticleVm: computed(() => toSelectedArticleViewModel({ articles: _articleResource.articles(), selectedArticleId: selectedArticleId(), }), ), })),
withEventHandlers((store, events = inject(Events)) => ({ setSelectedArticleOnSelected$: events.on(articleSelectionEvents.articleSelected).pipe( tap(({ articleId }) => { patchState(store, { selectedArticleId: articleId, }); }), ),
openDetailOnSelected$: events.on(articleSelectionEvents.articleSelected).pipe( map(({ articleId }) => articleNavigationIntentEvents.openDetail({ articleId, }), ), ), })),);Das ist die zentrale Stelle.
Der Store liest sich jetzt wie Orchestrierung:
articles → toArticleListViewModel() → listVm
articles + selectedArticleId → toSelectedArticleViewModel() → selectedArticleVmDie technische Resource-Absicherung bleibt in der Infrastructure.
Die Projektionsregel bleibt im benannten Mapper.
Der Store verbindet nur Sources, State, Projektionen und Intents.
Die Detailseite bekommt später nur noch:
facade.vm()6. Zwei Listener, zwei Verantwortungen
Abschnitt betitelt „6. Zwei Listener, zwei Verantwortungen“Wichtig ist: Das Event hat zwei Reaktionen.
Eine Reaktion verändert den lokalen Selection-State.
Eine andere Reaktion erzeugt einen Navigation-Intent.
Das bleibt bewusst getrennt.
articleSelected → setSelectedArticleId
articleSelected → openDetailIn einer klassischen Redux-Architektur wäre das ähnlich:
action: articleSelected
reducer: selectedArticleId setzen
effect: Router-Navigation auslösenDer Unterschied ist nur die technische Form.
Die Verantwortung bleibt dieselbe.
Ein Event darf mehrere Reaktionen haben.
Aber eine Reaktion sollte nicht mehrere Verantwortungen verstecken.
Deshalb steht hier nicht ein Handler, der alles macht:
articleSelected → patch selectedArticleId → navigateSondern zwei getrennte Reaktionen:
articleSelected → State ändern
articleSelected → neues Navigation Event erzeugenDas macht den Flow besser testbar.
Und es hält die Architektur lesbar.
7. Warum tap und map?
Abschnitt betitelt „7. Warum tap und map?“Im Store passieren zwei verschiedene Dinge.
State setzen ist ein Seiteneffekt innerhalb des Stores:
setSelectedArticleOnSelected$: events .on(articleSelectionEvents.articleSelected) .pipe( tap(({ articleId }) => { patchState(store, { selectedArticleId: articleId }); }), ),Hier ist tap passend.
Die Reaktion verändert Store-State.
Navigation dagegen wird nicht direkt ausgeführt.
Sie wird als neues Event beschrieben:
openDetailOnSelected$: events .on(articleSelectionEvents.articleSelected) .pipe( map(({ articleId }) => articleNavigationIntentEvents.openDetail({ articleId }), ), ),Hier ist map passend.
Aus einem Event wird ein neues Event.
tap → echte imperative Grenze im aktuellen Kontext
map → neues Event als ReaktionDer Store ruft also nicht direkt den Router auf.
Er erzeugt nur einen Navigation Intent.
8. Facades: Liste sendet Intent, Detail liest Ableitung
Abschnitt betitelt „8. Facades: Liste sendet Intent, Detail liest Ableitung“Jetzt trennen wir die Facades nach Nutzung.
Die Listen-Facade exponiert Liste und Auswahlaktion.
import { Injectable, inject } from '@angular/core';import { injectDispatch } from '@ngrx/signals/events';
import { ArticleStore } from '../+state/article.store';import { articleSelectionEvents } from '../+state/article-selection.events';
@Injectable()export class ArticleListFacade { private readonly store = inject(ArticleStore); private readonly dispatchSelection = injectDispatch(articleSelectionEvents);
readonly articles = this.store.listVm; readonly isLoading = this.store.isLoading; readonly error = this.store.error;
readonly selectArticle = (articleId: string): void => { this.dispatchSelection.articleSelected({ articleId }); };}Die Detail-Facade exponiert nur das Detail-ViewModel.
import { Injectable, inject } from '@angular/core';
import { ArticleStore } from '../+state/article.store';
@Injectable()export class ArticleDetailFacade { private readonly store = inject(ArticleStore);
readonly vm = this.store.selectedArticleVm;}Das ist der Schnitt:
List Facade → articles → selectArticle(articleId)
Detail Facade → vmDie Detail-Facade hat keine loadById()-Methode.
Das ist Absicht.
Die Detailseite ist eine Ableitung.
Kein Lade-Orchestrator.
9. Presentation: Liste setzt Auswahl
Abschnitt betitelt „9. Presentation: Liste setzt Auswahl“Die Liste rendert ihre Items und sendet beim Klick die Auswahl.
@let articles = facade.articles(); @if (articles) { @for (article of articles; track article.id) {<button type="button" class="article-list__item" (click)="facade.selectArticle(article.id)"> <span>{{ article.title }}</span> <small>{{ article.subtitle }}</small></button>} } @else {<app-list-placeholder />}Kein RouterLink.
Kein navigate().
Kein loadById().
Der Klick bedeutet fachlich:
Benutzer wählt Artikel → articleSelected({ articleId })Was danach passiert, ist nicht Aufgabe des Listentemplates.
10. Presentation: Detail rendert Ableitung
Abschnitt betitelt „10. Presentation: Detail rendert Ableitung“Die Detailseite liest nur ihr ViewModel.
@let vm = facade.vm();
@if (vm) { <article class="article-detail"> <img class="article-detail__image" [src]="vm.heroImageUrl" [alt]="vm.title" />
<h1>{{ vm.title }}</h1>
<p class="article-detail__subtitle"> {{ vm.subtitle }} </p>
<div class="article-detail__content"> @for (paragraph of vm.paragraphs; track paragraph) { <p>{{ paragraph }}</p> } </div> </article>} @else { <p>Kein Artikel ausgewählt.</p>}Das Template weiß nicht:
- woher die ID kommt
- wie Artikel geladen werden
- wie
findByIdfunktioniert - wann navigiert wurde
- ob eine Route Param existiert
Es rendert nur:
selectedArticleVm → TemplateDas ist der Lehrpunkt.
11. Routing als Reaktion
Abschnitt betitelt „11. Routing als Reaktion“Bis hierhin haben wir nur den Navigation Intent erzeugt:
articleSelected → articleNavigationIntentEvents.openDetail({ articleId })Irgendwo muss daraus echte Navigation werden.
Das ist eine imperative Grenze.
Dafür kann es einen kleinen Navigation Handler geben.
import { inject, Injectable } from '@angular/core';import { Router } from '@angular/router';import { Events, withEventHandlers } from '@ngrx/signals/events';import { tap } from 'rxjs';
import { articleNavigationIntentEvents } from '../+state/article-navigation-intent.events';
@Injectable()export class ArticleNavigationHandler { private readonly router = inject(Router); private readonly events = inject(Events);
readonly openDetailOnIntent$ = this.events.on(articleNavigationIntentEvents.openDetail).pipe( tap(({ articleId }) => { void this.router.navigate(['/articles', articleId]); }), );}Dieser Code ist bewusst nur skizziert.
Je nach Projekt kann Navigation auch über einen Router Store, einen globalen Navigation Handler oder eine andere Infrastruktur passieren.
Wichtig ist nur:
Navigation Intent → RouterNicht:
List Component → router.navigate(...)Und nicht:
Detail Component → Route Param lesen → loadById(...)Für diesen Slice ist Routing eine Reaktion auf Auswahl.
12. Der Preis dieses Schnitts
Abschnitt betitelt „12. Der Preis dieses Schnitts“Dieser Flow hat einen Preis.
Wenn der Benutzer direkt auf /articles/foo einsteigt, existiert im Store noch keine Auswahl.
Dann reicht selectedArticleVm() allein nicht aus.
Für echte Deeplinks braucht man eine zusätzliche Strategie:
Direkter Einstieg → URL enthält articleId → Store hat noch keine selectedArticleId → articles sind vielleicht noch nicht geladenDas ist kein Detail.
Das ist eine echte Anforderung.
Mögliche Lösungen wären:
- Route Param als Fallback lesen
- Detail-Retrieve-Slice für direkten Einstieg
- Resolver
- initiales
loadRequested - Rehydration aus URL
- Store mit
byId - Selection aus Route wiederherstellen
Dieser Artikel blendet das bewusst aus.
Nicht, weil es unwichtig ist.
Sondern weil es ein anderer Flow ist.
Hier geht es um:
Liste geladen → Benutzer klickt Element → Selection wird gesetzt → Detail rendert Ableitung → URL folgt als ReaktionDer klassische Route-Param-Flow optimiert auf Deeplink und Wiederherstellung.
Dieser Flow optimiert auf reaktive Ableitung im laufenden UI-Zustand.
Beides ist legitim.
13. Warum ich diesen Flow gern lehre
Abschnitt betitelt „13. Warum ich diesen Flow gern lehre“Ich lehre diesen Flow gern, weil er Teams zwingt, anders über Frontend-State nachzudenken.
Nicht:
Komponente startet → Komponente liest irgendwas → Komponente lädt irgendwas → Komponente baut irgendwasSondern:
Zustand verändert sich → Ableitungen aktualisieren sich → Templates rendernDas ist reaktiver.
Und es hält Komponenten flach.
Die Komponente fragt nicht:
Was muss ich im Init tun?
Sondern:
Welches Signal rendere ich?
Der Store fragt nicht:
Welche Komponente braucht gerade welche Daten?
Und auch nicht:
Darf ich diese Angular Resource gerade lesen?
Sondern:
Welche Projektion entsteht aus den bereitgestellten Source Signals und meinem Selection-State?
Das ist ein anderer Denkstil.
Und genau deshalb finde ich diesen Slice als Lehrbeispiel wertvoll.
Nicht als Dogma.
Sondern als Übung:
Auswahl als Zustand denken.Detail als Ableitung denken.Routing als Reaktion denken.Komponente flach halten.Select by ID muss nicht in der Detail-Komponente passieren.
Für den In-App-Flow aus einer geladenen Liste kann Auswahl zuerst fachlich entstehen:
List Item Click → articleSelected({ articleId }) → selectedArticleId → selectedArticle → selectedArticleVm → Detail TemplateRouting kann darauf reagieren:
articleSelected → openDetail({ articleId }) → Router NavigationDie Detail-Komponente lädt nicht.
Sie liest keine Route Param ID.
Sie führt kein loadById() aus.
Sie rendert nur eine Ableitung.
Die Infrastructure kapselt dabei die konkrete httpResource.
Der Store orchestriert nur das articles-Signal, den Selection-State und benannte ViewModel-Projektionen.
Das ist nicht immer der richtige Default.
Wenn Deeplinks und Reloads im Vordergrund stehen, braucht man eine URL-getriebene Wiederherstellungsstrategie.
Aber für den laufenden UI-Zustand ist dieser Schnitt sehr lehrreich:
Denkt mehr reaktiv.Denkt in Ableitungen.Haltet Komponenten flach.Keiner der Ansätze hat grundsätzlich unrecht.
Sie optimieren nur auf unterschiedliche Probleme.