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).

TypeScript model-basics.ts
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:

  1. Lesen im Template wie bei jedem Signal: {{ count() }}.
  2. Schreiben via .set(value) oder .update(fn) — beides triggert automatisch den valueChange-Output.
  3. 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.

TypeScript legacy-counter.component.ts
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:

HTML parent.legacy.html
<!-- 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.

TypeScript modern-counter.component.ts
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 + @Outputmodel()
Anzahl Properties2 (value, valueChange)1 (value)
Typ-Deklarationdoppelteinmalig (inferiert)
Schreiben im Childthis.value = x + emit(x)this.value.set(x)
Lesen im Template&#123;&#123; value &#125;&#125;&#123;&#123; value() &#125;&#125;
Emit-Logikmanuellautomatisch
Required-Variantenur @Input({ required: true })model.required&lt;T&gt;()
Change-DetectionZone.js-getriebenSignal-getrieben (zoneless-ready)

Drei Aufruf-Formen, drei Bedeutungen

Die Factory model() hat drei Overloads, die du je nach Use-Case wählst:

TypeScript model-overloads.ts
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:

TypeScript model-methods.ts
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.

TypeScript required-model.ts
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:

HTML bindings.html
<!-- 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:

TypeScript toggle.component.ts
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);
    }
}
TypeScript toggle-demo.component.ts
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).

TypeScript number-input.component.ts
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);
    }
}
HTML number-input.demo.html
<!-- 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()
ModulFormsModule (Template-Driven Forms)@angular/core — kein Modul-Import
ZielgruppeNative Form-Controlsbeliebige Custom-Komponenten
Validierungintegriert (Validators, ngForm)manuell im Component-Code
Form-Stateng-touched, ng-dirty, ng-validnicht vorhanden
ReaktivitätRxJS-basiert (valueChanges)Signal-basiert
Standardgebrauchsimple Form-FelderWiederverwendbare UI-Komponenten
Empfehlung 2026für komplexe Forms: Reactive Formsfü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.

TypeScript slider.component.ts
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

TypeScript gotchas.ts
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]);
    }
}
TypeScript model-with-transform.ts
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() ist T | 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

/ Weiter

Zurück zu Komponenten-Interaktion

Zur Übersicht