Two-Way-Binding zwischen Parent und Child war in Angular jahrelang ein zweistufiges Konstrukt: Ein @Input() für den Wert nach unten, ein passender @Output() mit EventEmitter und der Konventions-Endung Change für den Weg zurück nach oben. Jeder Schalter, jedes Custom-Input-Feld, jeder Akkordeon-Header brauchte beide Hälften — sauber, aber redundant.
Mit model() (seit v17.2 stable, in Angular 21 als Standard etabliert) bündelt das Framework beide Richtungen in einer einzigen Signal-API. Du schreibst count = model(0), und Angular generiert intern automatisch sowohl den Input count als auch den Output countChange. Der Parent bindet mit der bekannten Banana-in-a-Box-Syntax [(count)]="x", das Child mutiert über count.set(...) oder count.update(...). Versions-Baseline dieses Artikels ist Angular 21 (Nov 2025); ab v22 ist OnPush der Default, was die Signal-Integration noch direkter macht.
Two-Way-fähiges Signal-Input in einem API-Punkt
model() ist eine Factory-Funktion aus @angular/core, die ein ModelSignal<T> zurückgibt — eine Spezialform des WritableSignal, das gleichzeitig als Input und Output gegenüber dem Parent agiert. Die offizielle Doku formuliert es so: "model declares a writeable signal that is exposed as an input/output pair on the containing directive". Der Output-Name wird automatisch erzeugt, indem an den Property-Namen das Suffix Change angehängt wird. Aus count = model(0) wird also count (Input) plus countChange (Output).
import { Component, model } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="count.update(c => c + 1)">
Count: {{ count() }}
</button>
`
})
export class CounterComponent {
// Ein einziges Statement liefert:
// - Input "count"
// - Output "countChange"
// - WritableSignal-Schnittstelle für den Component-Author
count = model(0);
}Drei Eigenschaften sind dabei zentral:
- Lesen im Template wie bei jedem Signal:
{{ count() }}. - Schreiben via
.set(value)oder.update(fn)— beides triggert automatisch denvalueChange-Output. - Binden im Parent geschieht mit Banana-in-a-Box:
<app-counter [(count)]="myCounter" />.
Die alte Welt als Vergleichs-Basis
Bevor wir den Mehrwert von model() greifen können, brauchen wir die Referenz: So sah Two-Way-Binding vor v17.2 aus. Angulars Konvention für Two-Way verlangt zwei Properties — eine mit dem gewünschten Namen und eine zweite mit demselben Namen plus Change-Suffix als EventEmitter. Nur dann erkennt der Compiler die Banana-Box-Syntax als gültig.
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-legacy-counter',
standalone: true,
template: `
<button (click)="add()">Count: {{ value }}</button>
`
})
export class LegacyCounterComponent {
// 1. Eingehender Wert
@Input() value = 0;
// 2. Ausgehender Change-Event — Name MUSS exakt "valueChange" sein
@Output() valueChange = new EventEmitter<number>();
add() {
// Lokal ändern UND nach oben emittieren — beide Schritte manuell
this.value = this.value + 1;
this.valueChange.emit(this.value);
}
}Im Parent funktioniert die Banana-Box dann wie gewohnt:
<!-- Banana-Box ist nur Syntax-Zucker für: -->
<app-legacy-counter [(value)]="myValue" />
<!-- ... das hier: -->
<app-legacy-counter
[value]="myValue"
(valueChange)="myValue = $event" />Schon im Mini-Beispiel sind drei Schwachstellen sichtbar: Du musst value und valueChange per Konvention synchron halten, du musst nach jeder lokalen Mutation manuell emittieren und der Typ wird doppelt definiert (einmal number als Property, einmal als EventEmitter-Generic).
Dasselbe Problem, halb so viel Code
Dieselbe Komponente mit model() reduziert sich auf vier Zeilen Logik. Es gibt keinen EventEmitter mehr, kein .emit(), keine doppelten Type-Definitionen.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-modern-counter',
standalone: true,
template: `
<button (click)="add()">Count: {{ value() }}</button>
`
})
export class ModernCounterComponent {
// Input + Output + Writable in einem Statement
value = model(0);
add() {
// .update() schreibt UND emittiert valueChange — automatisch
this.value.update(v => v + 1);
}
}Das Mapping zwischen den beiden Welten lässt sich tabellarisch festhalten:
| Aspekt | @Input + @Output | model() |
|---|---|---|
| Anzahl Properties | 2 (value, valueChange) | 1 (value) |
| Typ-Deklaration | doppelt | einmalig (inferiert) |
| Schreiben im Child | this.value = x + emit(x) | this.value.set(x) |
| Lesen im Template | {{ value }} | {{ value() }} |
| Emit-Logik | manuell | automatisch |
| Required-Variante | nur @Input({ required: true }) | model.required<T>() |
| Change-Detection | Zone.js-getrieben | Signal-getrieben (zoneless-ready) |
Drei Aufruf-Formen, drei Bedeutungen
Die Factory model() hat drei Overloads, die du je nach Use-Case wählst:
import { model } from '@angular/core';
export class MyComponent {
// 1. MIT Default-Wert — Typ wird inferiert (number)
count = model(0);
// 2. OHNE Default — explizit typisiert, Initial-Wert ist undefined
// Resultat: ModelSignal<string | undefined>
label = model<string>();
// 3. REQUIRED — kein Default erlaubt, Binding ist Pflicht
user = model.required<{ id: string; name: string }>();
}Die Methoden auf dem zurückgegebenen ModelSignal decken sich mit WritableSignal:
export class MyComponent {
count = model(0);
inc() {
// Lesen — wie jedes Signal
const current = this.count();
// Wert ersetzen
this.count.set(current + 1);
// Wert transformieren — bevorzugt bei abhängigen Updates
this.count.update(v => v + 1);
// .asReadonly() ist NICHT auf ModelSignal verfügbar —
// dafür gibt es input() (read-only). model() ist immer writable.
}
}Wenn ein Default unsinnig wäre
Bei einigen Komponenten ergibt jeder mögliche Default-Wert einen falschen Eindruck. Ein EditUserForm ohne user ist nicht "leer" — er ist kaputt. model.required() macht die Property zum Pflicht-Input und lässt den Compiler den Build abbrechen, wenn der Parent das Binding vergisst.
import { Component, model } from '@angular/core';
interface User { id: string; name: string; }
@Component({
selector: 'app-edit-user',
standalone: true,
template: `
<input
[value]="user().name"
(input)="rename($any($event.target).value)" />
`
})
export class EditUserComponent {
// Kein Default — der Parent MUSS [(user)] oder [user] binden
user = model.required<User>();
rename(newName: string) {
this.user.update(u => ({ ...u, name: newName }));
}
}Wer im Parent <app-edit-user /> schreibt — also ohne Binding — bekommt einen Compile-Fehler, keinen stillen undefined-Bug zur Laufzeit. Der Compiler-Hinweis lautet sinngemäß "Required input 'user' from component EditUserComponent must be specified."
Banana-in-a-Box, explizit oder gar nicht
Die Banana-Box ist reiner Syntax-Zucker. Beide Schreibweisen kompilieren zum gleichen Ergebnis:
<!-- 1. Banana-in-a-Box (kompakt) -->
<app-modern-counter [(value)]="parentCount" />
<!-- 2. Explizit zerlegt (semantisch identisch) -->
<app-modern-counter
[value]="parentCount"
(valueChange)="parentCount = $event" />
<!-- 3. Wenn parentCount selbst ein Signal ist -->
<app-modern-counter
[value]="parentCount()"
(valueChange)="parentCount.set($event)" />Wichtig: Beim Einsatz mit Signals im Parent (Variante 3) brauchst du kein [(...)] — die Banana-Box erwartet eine schreibbare Property-Expression auf der rechten Seite. Ein Signal-Aufruf parentCount() ist eine Funktions-Auswertung, keine zuweisbare Reference. Die explizite Form mit parentCount.set($event) ist hier der saubere Weg. Ab Angular 21 gibt es zwar Diskussionen rund um direktes Two-Way mit Signals — bis das stable ist, gilt: Signal im Parent → explizit binden.
Vollständige Komponente mit checked = model(false)
Eine eigenständige Toggle-Komponente ist der Klassiker für Two-Way-Binding. Hier die Vollversion mit Child und Parent:
import { Component, model } from '@angular/core';
@Component({
selector: 'app-toggle',
standalone: true,
template: `
<button
type="button"
role="switch"
[attr.aria-checked]="checked()"
[class.is-on]="checked()"
(click)="toggle()">
{{ checked() ? 'An' : 'Aus' }}
</button>
`,
styles: `
button { padding: .5rem 1rem; border-radius: 999px; }
button.is-on { background: #2ecc71; color: white; }
`
})
export class ToggleComponent {
checked = model(false);
toggle() {
this.checked.update(v => !v);
}
}import { Component, signal } from '@angular/core';
import { ToggleComponent } from './toggle.component';
@Component({
selector: 'app-toggle-demo',
standalone: true,
imports: [ToggleComponent],
template: `
<app-toggle [(checked)]="darkMode" />
<p>Dark Mode ist {{ darkMode() ? 'aktiv' : 'inaktiv' }}.</p>
`
})
export class ToggleDemoComponent {
// Signal im Parent — bei Banana-Box geht das nur mit Property,
// also packen wir den Wert in eine simple Property:
darkMode = signal(false);
}Mischform: model() für den Wert, input() für Constraints
Nicht jede Property muss model() sein. Constraints wie min und max sollte der Parent setzen, das Child sollte sie nur lesen. Hier mischst du model() (für den Wert) mit input() (für die Grenzen).
import { Component, computed, input, model } from '@angular/core';
@Component({
selector: 'app-number-input',
standalone: true,
template: `
<div class="num-input">
<button (click)="dec()" [disabled]="atMin()">−</button>
<span>{{ value() }}</span>
<button (click)="inc()" [disabled]="atMax()">+</button>
</div>
`
})
export class NumberInputComponent {
// Two-Way: Wert geht beide Wege
value = model(0);
// One-Way: Grenzen kommen nur vom Parent
min = input(0);
max = input(100);
// Abgeleitete Disabled-States
atMin = computed(() => this.value() <= this.min());
atMax = computed(() => this.value() >= this.max());
inc() {
if (!this.atMax()) this.value.update(v => v + 1);
}
dec() {
if (!this.atMin()) this.value.update(v => v - 1);
}
}<!-- Two-Way für value, One-Way für die Grenzen -->
<app-number-input
[(value)]="quantity"
[min]="1"
[max]="20" />
<p>Bestellte Menge: {{ quantity }}</p>Die Trennung ist konzeptuell wichtig: value ändert sich durch User-Interaktion im Child und muss zurück zum Parent — Two-Way. min/max sind fixe Geschäfts-Constraints — One-Way reicht. Wer min versehentlich als model() deklariert, gibt dem Child unnötig die Macht, Geschäftsregeln zu ändern.
Die Formular-Welt vs. die Komponenten-API
Beide Konzepte heißen "Model" und nutzen die gleiche Banana-Box. Sie lösen aber unterschiedliche Probleme:
| Aspekt | [(ngModel)] | model() |
|---|---|---|
| Modul | FormsModule (Template-Driven Forms) | @angular/core — kein Modul-Import |
| Zielgruppe | Native Form-Controls | beliebige Custom-Komponenten |
| Validierung | integriert (Validators, ngForm) | manuell im Component-Code |
| Form-State | ng-touched, ng-dirty, ng-valid … | nicht vorhanden |
| Reaktivität | RxJS-basiert (valueChanges) | Signal-basiert |
| Standardgebrauch | simple Form-Felder | Wiederverwendbare UI-Komponenten |
| Empfehlung 2026 | für komplexe Forms: Reactive Forms | für Komponenten-APIs: Standard |
Faustregel: [(ngModel)] nimmst du, wenn du in einem Formular ohne Reactive-Forms-Setup schnell ein Feld an State binden willst. model() baust du, wenn du eine eigene Komponente schreibst, die sich wie ein Form-Control verhalten soll, ohne ControlValueAccessor-Boilerplate. Für reichhaltige Form-Trees mit Validierung und Async-Logik nimmst du Reactive Forms (in v21+ ergänzt durch Signal Forms).
Wert intern transformieren, sauber nach außen
Ein typischer Anwendungsfall sind Wrapper, die einen Wert vom Nutzer-API-Format in ein internes Arbeitsformat überführen. Beispiel: Ein Slider, der außen 0..100 (Prozent) spricht, intern aber mit 0..1 (normalisiert) arbeitet.
import { Component, computed, model } from '@angular/core';
@Component({
selector: 'app-slider',
standalone: true,
template: `
<input
type="range"
min="0"
max="100"
[value]="percent()"
(input)="onInput($any($event.target).value)" />
<span>{{ percent() }} %</span>
`
})
export class SliderComponent {
// Außen sichtbar: 0..1 (normalisiert)
value = model(0.5);
// Intern: 0..100 (Prozent für das native Range-Input)
percent = computed(() => Math.round(this.value() * 100));
onInput(raw: string) {
const n = Number(raw) / 100;
this.value.set(Math.max(0, Math.min(1, n)));
}
}Der Parent sieht nichts von der Normalisierung — er bindet [(value)]="opacity" und arbeitet mit 0..1. Die Innerei gehört der Komponente.
Stolperfallen und Limitierungen
export class TodoListComponent {
items = model<string[]>([]);
// FALSCH — Mutation, kein valueChange-Event
addBad(item: string) {
this.items().push(item);
}
// RICHTIG — neue Reference, valueChange feuert
addGood(item: string) {
this.items.update(arr => [...arr, item]);
}
}import { Component, model, numberAttribute } from '@angular/core';
@Component({
selector: 'app-rating',
standalone: true,
template: `<span>Rating: {{ stars() }}</span>`
})
export class RatingComponent {
// Erstes Generic = Schreib-Typ vom Parent (string aus HTML-Attribut)
// Zweites Generic = interner Typ (number)
stars = model.required<string, number>({ transform: numberAttribute });
// Parent kann <app-rating stars="3" /> schreiben — intern landet 3 als number
}Weitere Punkte, die in Code-Reviews regelmäßig auftauchen:
- Ein
model()ohne Default und ohne.required()istT | undefined— alle Lese-Stellen müssen das einplanen. model()ist nicht für tief verschachtelte Objekt-Trees gedacht. Wenn der Wert ein komplexes Form-Objekt ist, baue stattdessen Reactive Forms / Signal Forms (v21+).- Zu jeder Mutation feuert genau ein
valueChange-Event — Batching mehrerer.set()-Aufrufe gibt es nicht. Plane das ein, wenn der Parent-Listener teure Arbeit macht.
Weitere Infos zu model()
Writable und Read-only zugleich
Aus Component-Author-Sicht ist model() ein voll schreibbares Signal mit .set() und .update(). Aus Parent-Sicht wirkt es wie ein Input plus Output: Der Parent setzt Werte über das Binding und reagiert auf valueChange. Die "Doppelnatur" ist die ganze Idee — eine Property, zwei Perspektiven.
valueChange nur bei .set()/.update()
Eine direkte Mutation des gehaltenen Werts (z.B. items().push(x) oder user().name = 'x') triggert KEIN valueChange-Event. Du brauchst zwingend eine neue Reference: items.update(a => [...a, x]). Das ist dieselbe Immutability-Regel wie bei normalen Signals — bei model() hat sie aber zusätzlich Auswirkung auf den Parent.
Funktioniert auch ohne Two-Way
Wenn der Parent nur [value]="x" schreibt, verhält sich model() wie ein input(). Bindet er nur (valueChange)="...", wirkt es wie ein output(). Two-Way ist optional — Banana-Box ist nur die kompakteste der drei Schreibweisen.
model.required mit Transform kombinierbar
Du kannst beides koppeln: stars = model.required<string, number>({ transform: numberAttribute }). Das erste Generic ist der Typ, den der Parent schreiben darf, das zweite der intern gespeicherte Typ. Praktisch für Komponenten, die HTML-Attribute akzeptieren sollen.
Kein Lifecycle-Hook für Tracking nötig
Wer früher mit ngOnChanges auf Input-Wechsel reagieren musste, schreibt heute effect(() => doSomething(value())) im Constructor — das feuert automatisch bei jeder Wert-Änderung, egal ob vom Parent oder lokal getriggert. Sauberer und ohne SimpleChanges-Boilerplate.
Last-Write-Wins-Semantik
Wenn Parent und Child gleichzeitig schreiben (z.B. der Parent setzt einen neuen Wert per Binding, während der Child gerade .update() aufruft), gibt es keine Lock-Semantik — die letzte Schreib-Operation gewinnt. In der Praxis selten ein Problem, aber gut zu wissen für komplexe Streams.
Nicht für tiefe Form-Trees
model() ist eine Komponenten-API für einzelne Werte oder kleine Objekte. Für komplexe Formulare mit Validierung, Async-Checks und verschachtelten Gruppen nimmst du Reactive Forms — oder ab Angular 21 die neuen Signal Forms. model() ersetzt sie nicht.
Ergänzt @if/@for, ersetzt sie nicht
Control-Flow-Direktiven leben im Template, model() lebt im Component-Code. Sie sind komplementär: Du nutzt @if (user()) im Template und definierst user = model.required<User>() in der Klasse. Kein Konflikt, sondern saubere Trennung.
Weiterführende Ressourcen
Externe Quellen
- model() API – angular.dev
- Two-way binding – angular.dev
- Component inputs – angular.dev
- Component outputs – angular.dev