1. Einführung

Das Ziel dieses Tutorials ist es, ein einfaches Angular-Projekt mithilfe von Single Source of Truth (SSOT) und Signals darzustellen. Dabei soll gezeigt werden, wie sich durch den Einsatz von Signals eine reaktive und übersichtliche Architektur umsetzen lässt – ganz ohne komplexes RxJS-Handling und ohne mehrere, verstreute Zustände.

2. Warum Angular mit SSOT und Signals?

2.1. Single Source of Truth (SSOT)

2.1.1. Derzeitige Probleme

❌ Ohne zentrale Datenquelle speichern Komponenten ihre eigenen Datenkopien, was zu unterschiedlichen Ansichten derselben Information führen kann.

❌ Wenn sich Daten an mehreren Stellen ändern, muss man überall händisch aktualisieren – das ist fehleranfällig.

2.1.2. Was ist Single Source of Truth?

Das Konzept der Single Source of Truth bedeutet, dass der Zustand einer Anwendung zentral verwaltet wird, sodass alle Komponenten konsistente und synchronisierte Daten haben. Anstatt den Zustand in mehreren voneinander unabhängigen Quellen zu speichern, gibt es eine einzige, zuverlässige Quelle.

2.1.3. Lösung mit Single Source of Truth

✅ Konsistenter Zustand

  • Es gibt eine einzige Quelle, die den Zustand hält.

  • Alle Komponenten greifen auf dieselben aktuellen Daten zu.

✅ Bessere Skalierbarkeit

  • Neue Features können einfach in die zentrale State-Verwaltung integriert werden.

  • Der Code bleibt strukturiert und verständlich, auch bei großen Anwendungen.

2.2. Signals

2.2.1. Derzeitige Probleme

❌ Ineffiziente Change Detection (Performance-Problem)

  • Angular verwendet standardmäßig Zone.js, das nach jeder Benutzerinteraktion die gesamte UI auf Änderungen überprüft.

  • Die Folgen: Unnötige UI-Updates führen zu Performance-Problemen, besonders in großen Apps

❌ Komplexe State-Verwaltung

  • Ohne Signals benötigt man für reaktive Zustände oft RxJS oder NgRx, was zusätzlichen Code erfordert.

  • Die Folgen: Folge: Der Code wird komplizierter als nötig, besonders für einfache Datenflüsse mit Observables, Subscriptions und Operators

2.2.2. Was ist Signal?

Signals in Angular sind reaktive Mechanismen, die es ermöglichen, den Zustand einer Anwendung zu verwalten und Änderungen gezielt zu verfolgen. Ein Signal ist dabei ein Wrapper um einen Wert, der bei Änderungen automatisch alle abhängigen Komponenten oder Funktionen benachrichtigt. Er ändert nur die Komponente, wenn sich etwas geändert hat.

  • Signal as Producer:

    • Signal speichert sich einen Initialwert (4712)

    • Über die set-Methode kann der Wert geändert werden

  • Consumer

    • Eine Komponente oder Funktion, die das Signal liest.

    • Sobald der Wert des Signals sich ändert, wird der Consumer automatisch benachrichtigt (notify).

    • Der Consumer liest den aktuellen Wert des Signals lesen, wenn sich der Wert im Signal ändert

2.2.3. Lösung mit Signals

✅ Bessere Performance

  • Signals aktualisieren die UI, wenn Komponenten von einer Datenänderung betroffen sind

  • Dadurch werden nur die notwendigen Teile der Anwendung neu gerendert, was die Effizienz der Change Detection erhöht.

✅ Einfacherer Code und reaktive Programmierung

  • Kein kompliziertes RxJS-Handling mehr für einfache Zustandsänderungen.

  • Weniger Boilerplate-Code

2.2.4. Wichtige Arten von Signals

  • Computed Signals: Ein Computed Signal berechnet automatisch einen neuen Wert, wenn sich ein anderes Signal ändert.

Computed Signals
import { signal, computed } from '@angular/core';

const count = signal(2);
const squared = computed(() => count() * count());

console.log(squared());
count.set(3);
console.log(squared());
Output
4
9
  • Effects: Ein Effect reagiert auf Signal-Änderungen und führt eine Aktion aus.

Effect
import { signal, effect } from '@angular/core';

const name = signal('Candice');

effect(() => {
  console.log(`Hallo, ${name()}!`);
});

name.set('Oleg');
Output
Hallo, Candice!
Hallo, Oleg!
  • LinkedSignals: Ein Linked Signal ermöglicht, verschiedene Signale miteinander zu verknüpfen, sodass Änderungen in einem Signal automatisch an ein anderes weitergegeben werden.

Computed Signals
import { signal, linked } from '@angular/core';

const source = signal(10);
const linkedSignal = linked({
  get: () => source(),     // Liest den Wert von `source`
  set: (value) => source.set(value) // Setzt den Wert in `source`
});

console.log(linkedSignal()); // 10
linkedSignal.set(20);        // Ändert auch `source`
console.log(source());       // 20
Output
10
20

3. Angular Beispiel

3.1. Voraussetzungen

  • Angular CLI

  • Node.js

3.2. Aufsetzen des Angular-Projekts

  • Neues Terminal öffnen und Angular-Projekt erstellen

ng new ssot-demo

Es werden folgende Fragen gestellt und einfach mit Enter weiterklicken:

✔ Which stylesheet format would you like to use? CSS             [ https://developer.mozilla.org/docs/Web/CSS                     ]
✔ Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? No
  • Abhängigkeiten und Packages herunterladen

npm install
  • derzeitiges Verzeichnis anschauen

tree -I "node_modules" (1)
1 zeigt die Unterordner vom Ordner "node_modules" nicht an
Derzeitiger Stand
├── angular.json
├── package.json
├── package-lock.json
├── public
│   └── favicon.ico
├── README.md
├── src
│   ├── app
│   │   ├── app.component.css
│   │   ├── app.component.html
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   ├── app.config.ts
│   │   └── app.routes.ts
│   ├── index.html
│   ├── main.ts
│   └── styles.css
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json
  • Starten der Angular-App

ng serve --open (1)
1 öffnet automatisch die Angular-App
angular startsite
Figure 4. Startseite

3.3. Model

Um Daten speichern zu können, wird ein Modell erstellt, das als zentrale Struktur für den Zustand dient. Dabei holen wir die Todos über eine öffentliche API von jsonplaceholder und ergänzen das Modell um weitere einfache Felder.

  • Neues Terminal öffnen und Ordner "model" in ssot-demo/src/app erstellen

mkdir model

Erstellen Sie folgende *.ts Dateien im Ordner model:

Index
index.ts
export {store} from './model'
export type {Model} from './model'
export type {Todo} from './todo'
Model
model.ts
import {Todo} from "./todo";
import {signal} from "@angular/core";

export interface Model {
  readonly firstName: string
  readonly lastName: string
  readonly todos: Todo[]
  readonly todosLoaded: boolean;
}

export const initial: Model =
{
  firstName: "Candice",
  lastName: "Oleg",
  todos: [],
  todosLoaded: false
}
export const store = signal<Model>(initial); (1)
1 die Single Source of Truth, wo wir unsere Daten zentral lagern. Als Typ wird ein Signal verwendet, damit alle Komponenten, die auf diese Daten zugreifen, automatisch informiert werden, wenn sich etwas ändert.
Todo
todo.ts
export interface Todo {
  userId: number
  id: number
  title: string
  completed: boolean
}
Derzeitiger Stand
├── angular.json
├── package.json
├── package-lock.json
├── public
│   └── favicon.ico
├── README.md
├── src
│   ├── app
│   │   ├── app.component.css
│   │   ├── app.component.html
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   ├── app.config.ts
│   │   ├── app.routes.ts
│   │   └── model
│   │       ├── index.ts
│   │       ├── model.ts
│   │       └── todo.ts
│   ├── index.html
│   ├── main.ts
│   └── styles.css
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json

3.4. Service

  • Neues Terminal öffnen und Ordner services in ssot-demo/src/app erstellen

mkdir services
cd services
  • Im Ordner services folgende Service erstellen:

ng g s app-state
ng g s store
Output
CREATE src/app/services/app-state.service.spec.ts (368 bytes)
CREATE src/app/services/app-state.service.ts (137 bytes)

CREATE src/app/services/store.service.spec.ts (352 bytes)
CREATE src/app/services/store.service.ts (134 bytes)
Derzeitiger Stand
├── angular.json
├── package.json
├── package-lock.json
├── public
│   └── favicon.ico
├── README.md
├── src
│   ├── app
│   │   ├── app.component.css
│   │   ├── app.component.html
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   ├── app.config.ts
│   │   ├── app.routes.ts
│   │   ├── model
│   │   │   ├── index.ts
│   │   │   ├── model.ts
│   │   │   └── todo.ts
│   │   └── services
│   │       ├── app-state.service.spec.ts
│   │       ├── app-state.service.ts
│   │       ├── store.service.spec.ts
│   │       └── store.service.ts
│   ├── index.html
│   ├── main.ts
│   └── styles.css
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json
store
store-service.ts
import { Injectable } from '@angular/core';
import { store } from '../model';

@Injectable({
  providedIn: 'root'
})
export class StoreService {

  constructor() { }
  (1)
  get store () {
    return store
  }
}
1 Der StoreService stellt den zentralen Zustand (store) als Signal anwendungsweit zur Verfügung.
app-state
app-state-service.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Model, Todo } from '../model';
import { initial } from '../model/model';
import {StoreService} from "./store.service";

@Injectable({
  providedIn: 'root',
})
export class AppStateService {
  // jsonplaceholder, um Fake-Daten zu holen
  private readonly API_URL = 'https://jsonplaceholder.typicode.com/todos?_limit=20'; (1)

  // Single Source of Truth als Signal
  (2)
  private state = inject(StoreService).store

  // Computed Signals für Selektoren
  (3)
  readonly todosSignals = computed(() => this.state().todos);
  readonly firstName = computed(() => this.state().firstName);
  readonly lastName = computed(() => this.state().lastName);

  constructor(private http: HttpClient) {}

  // Zustand aktualisieren
  (4)
  updateState(recipe: (state: Model) => Model) {
    this.state.update(recipe);
  }

  addTodo(todo: Todo) {
    this.updateState((state) => ({
      ...state,
      todos: [...state.todos, todo],
    }));
  }

  toggleTodoStatus(id: number) {
    this.updateState((state) => ({
      ...state,
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    }));
  }

  removeTodo(id: number) {
    this.updateState((state) => ({
      ...state,
      todos: state.todos.filter((todo) => todo.id !== id),
    }));
  }

  (5)
  loadTodos() {
    if (!this.state().todosLoaded) {
      this.http
        .get<Todo[]>(this.API_URL)
        .subscribe((todos) => {
          this.updateState((state) => ({
            ...state,
            todos,
            todosLoaded: true,
          }));
        });
    }
  }
}

AppStateService dient zum Verwalten der ganzen State (Zustand) auf Basis der SSOT, z.B. einfügen, löschen etc…​

1 Die URL für die API, um die Fake-Daten zu holen
2 es wird sich der store geholt (SSOT)
3 Es werden gezielt auf Teile des Zustands zugegriffen ⇒ wird automatisch aktualisiert, wenn sich etwas ändert.
4 mit recipe → die den Zustand verändert und ein neues Model-Objekt zurückgibt - vergleichbar mit einem "Reducer" in anderen Architekturen.
5 holt sich die Daten von jsonplaceholder

3.5. Component

3.5.1. Overview

Es wird eine einfache Seite dargestellt, die die firstName und lastName anzeigt. Wenn man die Felder ändert, werden sie sofort geändert.

  • Neues Terminal öffnen und Ordner components in ssot-demo/src/app erstellen

mkdir components
cd components
  • Im Ordner services folgende Component erstellen:

ng g c overview
Output
CREATE src/app/components/overview/overview.component.css (0 bytes)
CREATE src/app/components/overview/overview.component.html (23 bytes)
CREATE src/app/components/overview/overview.component.spec.ts (606 bytes)
CREATE src/app/components/overview/overview.component.ts (222 bytes)
Derzeitiger Stand
├── angular.json
├── package.json
├── package-lock.json
├── public
│   └── favicon.ico
├── README.md
├── src
│   ├── app
│   │   ├── app.component.css
│   │   ├── app.component.html
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   ├── app.config.ts
│   │   ├── app.routes.ts
│   │   ├── components
│   │   │   └── overview
│   │   │       ├── overview.component.css
│   │   │       ├── overview.component.html
│   │   │       ├── overview.component.spec.ts
│   │   │       └── overview.component.ts
│   │   ├── model
│   │   │   ├── index.ts
│   │   │   ├── model.ts
│   │   │   └── todo.ts
│   │   └── services
│   │       ├── app-state.service.spec.ts
│   │       ├── app-state.service.ts
│   │       ├── store.service.spec.ts
│   │       └── store.service.ts
│   ├── index.html
│   ├── main.ts
│   └── styles.css
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json
Overview
overview.component.html
<h1>Hello {{ firstName() }} {{ lastName()}}!</h1>
<form>
  <fieldset>
    <label>
      First name
      <input #name
             value="{{ firstName() }}"
             placeholder="First name"
             autocomplete="given-name"
             (input)="onFirstNameChanged(name.value)"
      />
    </label>
    <label>
      Email
      <input #last
             value="{{ lastName() }}"
             placeholder="lastName"
             autocomplete="lastName"
             (input)="onLastNameChanged(last.value)"
      />
    </label>
  </fieldset>
</form>
overview.component.ts
import {Component, inject} from '@angular/core';
import {AppStateService} from '../../services/app-state.service';

@Component({
  selector: 'app-overview',
  imports: [],
  templateUrl: './overview.component.html',
  styleUrl: './overview.component.css'
})
export class OverviewComponent {
  (1)
  state = inject(AppStateService)
  firstName = inject(AppStateService).firstName;
  lastName = inject(AppStateService).lastName;

  (2)
  onFirstNameChanged(value: string) {
    this.state.updateState(state => ({
      ...state,
      firstName: value
    }));
  }

  onLastNameChanged(value: string) {
    this.state.updateState(state => ({
      ...state,
      lastName: value
    }));
  }
}
1 Es werden nur die benötigten Daten für diese Komponente injiziert und geholt
2 Sie ruft updateState() aus dem AppStateService auf und ändert nur den firstName im zentralen Zustand. Das gleiche gitl für onLastNameChanged(…​)

Um die Overview-Component anzeigen zu können, müssen wir unsere Router und die app.component.html konfigurieren. Die app.routes.ts findet man in ssot-demo/src/app

Router
app.routes.ts
import { Routes } from '@angular/router';
import {OverviewComponent} from '/components/overview/overview.component';

export const routes: Routes = [
{ path: "", redirectTo: "overview", pathMatch: "full" }, (1)
{ path: "overview", component: OverviewComponent} (2)
];
1 Sie leitet automatisch weiter zur Route /overview. pathMatch: "full" bedeutet, es muss exakt der leere Pfad sein (/), damit es weitergeleitet wird
2 Wenn der User /overview in die URL eingibt, wird die OverviewComponent geladen.
app.component.html
<header>

</header>
<main class="container">
<nav>
<ul>
<li><strong>Demonstration Signals and Single Source of Truth</strong></li>
</ul>
<ul>
<li><a [routerLink]="'/overview'">Overview</a></li> (1)
</ul>
</nav>
<router-outlet/> (2)

</main>
<footer>

</footer>
1 routerLink: Wenn man draufklickt, lädt Angular die OverviewComponent und ersetzt den Inhalt im <router-outlet>
2 Platzhalter, wo Angular die jeweils passende Komponente basierend auf der aktuellen Route anzeigt
angular problem
Figure 5. Angular httpClient-Provider Fehler
  • Wir stoßen jedoch auf ein httpClient-Provider Fehler

  • Um den Fehler zu beheben, müssen wir in der app.config.ts in ssot-demo/src/app den provideHttpClient() hinzufügen:

app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import {provideHttpClient} from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient()]
};
angular overview

Um die html etwas schöner zu gestalten, müssen wir in der index.html in ssot-demo/src eine externe .css importieren:

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>RxJsDemo</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <link
    rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
  />
</head>
<body>
<app-root></app-root>
</body>
</html>
angular overview better

3.5.2. Todo

Diese Komponente demonstriert, wie man eine Todo-Liste mit Signals und zentralem Zustand (SSOT) verwaltet – ohne RxJS. Sie zeigt, wie man Daten anzeigen, aktualisieren und verwalten kann - zentral und klar getrennt von der Logik.

  • Neues Terminal öffnen und im Ordner components in ssot-demo/src/app folgende Component erstellen:

ng g c todo
Output
CREATE src/app/components/todo/todo.component.css (0 bytes)
CREATE src/app/components/todo/todo.component.html (19 bytes)
CREATE src/app/components/todo/todo.component.spec.ts (578 bytes)
CREATE src/app/components/todo/todo.component.ts (206 bytes)
Derzeitiger Stand
├── angular.json
├── package.json
├── package-lock.json
├── public
│   └── favicon.ico
├── README.md
├── src
│   ├── app
│   │   ├── app.component.css
│   │   ├── app.component.html
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   ├── app.config.ts
│   │   ├── app.routes.ts
│   │   ├── components
│   │   │   ├── overview
│   │   │   │   ├── overview.component.css
│   │   │   │   ├── overview.component.html
│   │   │   │   ├── overview.component.spec.ts
│   │   │   │   └── overview.component.ts
│   │   │   └── todo
│   │   │       ├── todo.component.css
│   │   │       ├── todo.component.html
│   │   │       ├── todo.component.spec.ts
│   │   │       └── todo.component.ts
│   │   ├── model
│   │   │   ├── index.ts
│   │   │   ├── model.ts
│   │   │   └── todo.ts
│   │   └── services
│   │       ├── app-state.service.spec.ts
│   │       ├── app-state.service.ts
│   │       ├── store.service.spec.ts
│   │       └── store.service.ts
│   ├── index.html
│   ├── main.ts
│   └── styles.css
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json
Todo
todo.component.html
<div>
  <h3>Todos (Signal)</h3>
  <ul>
    @for (todo of todos();track todo){
      <li>
        <input
          type="checkbox"
          [checked]="todo.completed"
          (change)="toggleTodoStatus(todo.id)"
        />
        {{ todo.title }}
        <button (click)="deleteTodo(todo.id)">Delete</button>
      </li>
    }
  </ul>
  <div>
    <input [(ngModel)]="newTodoTitle" placeholder="New Todo" />
    <button (click)="addTodo()">Add Todo</button>
  </div>
</div>
todo.component.ts
import {Component, OnInit} from '@angular/core';
import {AppStateService} from '../../services/app-state.service';
import {FormsModule} from '@angular/forms';

@Component({
  selector: 'app-todo',
  imports: [
    FormsModule
  ],
  templateUrl: './todo.component.html',
  styleUrl: './todo.component.css'
})
export class TodoComponent implements OnInit{
  newTodoTitle = '';

  constructor(private appState: AppStateService) {}

  ngOnInit() {
    this.appState.loadTodos(); (1)
  }

  get todos() {
    return this.appState.todosSignals; // Getter für die todos
  }

  // Erstellt eine neue Todo
  addTodo() {
    if (this.newTodoTitle.trim()) {
      this.appState.addTodo({
        userId: 1,
        id: Date.now(),
        title: this.newTodoTitle.trim(),
        completed: false,
      });
      this.newTodoTitle = '';
    }
  }

  // Umschalten der Todos auf true oder false
  toggleTodoStatus(id: number) {
    this.appState.toggleTodoStatus(id);
  }
  // Löschen der Todo
  deleteTodo(id: number) {
    this.appState.removeTodo(id);
  }
}
1 Wenn die Komponente initialisiert wird, lädt sie die Todos, indem sie this.appState.loadTodos() aufruft

Um diese Todo-Component anzeigen zu können, müssen wir diese noch in der Router und in der app.component.html konfigurieren

Router
app.routes.ts
import { Routes } from '@angular/router';
import {OverviewComponent} from './components/overview/overview.component';
import {TodoComponent} from './components/todo/todo.component';

export const routes: Routes = [
  { path: "", redirectTo: "overview", pathMatch: "full" },
  { path: "overview", component: OverviewComponent},
  { path: "todo", component: TodoComponent}
];
app.component.html
app.component.html
<header>

</header>
<main class="container">
  <nav>
    <ul>
      <li><strong>Demonstration Signal and Single Source of Truth</strong></li>
    </ul>
    <ul>
      <li><a [routerLink]="'/overview'">Overview</a></li>
      <li><a [routerLink]="'/todo'">Todo</a></li>
    </ul>
  </nav>
  <router-outlet/>

</main>
<footer>

</footer>
angular firstpage
angular secondpage

3.6. Beispiel: Vorgang eines Löschens von einem Todo

vorgang delete
  1. Situation: Der Benuter klickt auf den "Delete Button" in der TodoComponent (Component)

  2. Es wird eine Action ausgelöst, da er die Methode deleteTodo() aufgerufen hat (Action)

  3. Die AppStateService ruft in der deleteTodo() die updateState() auf. (AppStateService)

  4. Mittels der Methode updateState erzeugt einen neuen Zustand, indem das Todo aus state.todos entfernt wird (Reducer)

  5. der Store wird aktualisiert → alle Komponente, die todosSignals() verwenden, erhalten automatisch den neuen Zustand. (Store)

  6. Der Selector passt die UI an → computed()-Signal erkennt die Änderung und informiert die Component automatisch. (Selector) Das gelöschte Todo verschwindet aus der UI ohne manuelles Refresh. (Component)

Fluss des Löschen in der Grafik:

Component → Action → AppStateService → Reducer → Store → Selector → Component aktualisiert sich automatisch.

4. Glossar

  • Signal: Reaktive Quelle von Zustandsänderungen.

    • Computed Signal: Dynamisch berechneter Wert basierend auf anderen Signals.

    • Effect Signal: Signal, das Seiteneffekte bei Änderungen auslöst.

    • Linked Signal: Signal, das von anderen Signals abhängt und sich mit deren Änderungen aktualisiert.

  • Single Source of Truth: Der Zustand der Anwendung wird zentral gespeichert.

5. Quelle