Angular model(): The Two-Way Binding Primitive, Desugared [2026]
When a component needs to BOTH receive a value from its parent AND notify the parent when it changes, you reach for model(). Added in v17.2, it bundles input() and output() into a single signal primitive that lights up the [(prop)] two-way binding syntax in templates. This lesson walks the API and the desugaring rules behind [()].
This is lesson 3.3, following lesson 3.2 (effect vs computed). You should be comfortable with input() (lesson 2.4) and output() (lesson 2.5) before reading on — model() builds on both.
The shape #
A model() is a writable signal that the parent can both supply AND observe:
import { Component, model } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<button (click)="dec()">-</button>
{{ count() }}
<button (click)="inc()">+</button>
`,
})
export class CounterComponent {
count = model(0); // two-way signal, default 0
inc() { this.count.update(n => n + 1); }
dec() { this.count.update(n => n - 1); }
}
The parent:
<app-counter [(count)]="current" />
When the child writes this.count.set(5), the parent's current updates to 5. When the parent writes current = 10, the child's count() updates to 10. Both directions, one binding.
What [(prop)] desugars to #
Two-way binding is syntactic sugar for a property binding plus an event binding:
<!-- This: -->
<app-counter [(count)]="current" />
<!-- Desugars to: -->
<app-counter [count]="current" (countChange)="current = $event" />
Three mechanical rules:
- The
[prop]part sets the model's value (same asinput()) - The
(propChange)part receives emissions when the model is written from the child current = $eventis the parent's automatic write — the value comes in, the parent re-renders
For [()] to work, the child must expose a model() (which auto-creates the matching propChange emitter). An input() + manual output<T>() pair also works as long as the output is named <propName>Change.
When to use model() vs input() #
The rule of thumb:
| Want | Use |
|---|---|
| Parent sets a value the child only READS | input() |
| Parent sets a value AND the child writes back | model() |
| Child fires arbitrary events back (no shared state) | output() |
Three real cases:
- Form input components (a custom
<app-text-input>wrapping<input>) — usemodel()for the text value - Toggles, switches, sliders — use
model()for the on/off or numeric value - Wrapper components that need to propagate state changes — use
model()to keep the parent's state in sync
If the child only displays the value (a <user-avatar> showing a passed User), use input(). The two-way machinery is overhead you do not need.
Required models #
Like input(), model() has a required variant:
count = model.required<number>(); // parent MUST pass [(count)]="..."
value = model<string>(''); // optional, default ''
The build fails if a parent omits the required two-way binding. Useful for form-control wrappers where the value is the whole point.
Inside the component: reads, writes, derivations #
model() returns a ModelSignal<T> — a writable signal you read with () and write with .set()/.update(). It also participates in the reactive graph:
export class TextInputComponent {
value = model.required<string>();
trimmed = computed(() => this.value().trim());
isEmpty = computed(() => this.trimmed().length === 0);
constructor() {
effect(() => console.log('Value changed:', this.value()));
}
onInput(event: Event) {
this.value.set((event.target as HTMLInputElement).value);
}
}
Reading is identical to input(). Writing is the extra power — this.value.set(...) propagates back to the parent automatically.
Two-way binding on native inputs — ngModel #
The most-used [()] binding in legacy Angular code is [(ngModel)]:
<input [(ngModel)]="username" />
That is just two-way binding on a model-like API the Forms module provides. In 2026 the recommended approach for forms is signal forms (lesson 4.1) which uses model() underneath; [(ngModel)] lives on as a template-driven forms pattern.
A real-world wrapper: text input #
import { Component, model } from '@angular/core';
@Component({
selector: 'app-text-input',
template: `
<label>
{{ label() }}
<input
type="text"
[value]="value()"
(input)="value.set($any($event.target).value)"
[placeholder]="placeholder()"
/>
</label>
`,
})
export class TextInputComponent {
label = input.required<string>();
value = model.required<string>();
placeholder = input('');
}
Usage:
<app-text-input label="Name" [(value)]="name" placeholder="Enter your name" />
The parent's name and the child's value() stay in lockstep. The child reads the value for its <input>'s [value] binding and writes it back via value.set(...) on each (input) event.
A toggle that uses model() for state #
@Component({
selector: 'app-toggle',
template: `
<button
role="switch"
[attr.aria-checked]="checked()"
(click)="checked.set(!checked())"
[class.on]="checked()"
>
{{ checked() ? 'On' : 'Off' }}
</button>
`,
})
export class ToggleComponent {
checked = model(false);
}
Usage:
<app-toggle [(checked)]="darkMode" />
The parent's darkMode flips when the user clicks the toggle. The toggle's internal state, the ARIA attribute, the visual class — all wired through one model signal.
Models propagate to parent immediately #
When the child does this.value.set(x), the parent's bound variable updates synchronously in the same change-detection tick. The parent re-renders if any of its templates depend on the variable. This is the same propagation you would get with the desugared (valueChange)="variable = $event" syntax.
If you need to BATCH multiple model changes (e.g., a form with five fields all writing models), the framework already handles batching — multiple writes in the same synchronous block trigger one change detection.
Cycles and parent-child loops #
Writing to a model() from a parent does NOT trigger an emit from the child. Only child-side writes emit back to the parent. This prevents the parent → child → parent → child cycle that would otherwise need careful guarding.
// Parent
this.current = 10; // child's count() becomes 10; child's writes are NOT triggered
// Child
this.count.set(20); // parent's current becomes 20; parent's templates re-render
The asymmetry is deliberate. It makes two-way binding safe by default.
Bridging models with effects in the parent #
The parent can react to a model's writes via a regular effect on the bound variable (which is just a signal of its own):
@Component({
template: `<app-toggle [(checked)]="darkMode" />`,
})
export class HostComponent {
darkMode = signal(false);
constructor() {
effect(() => {
console.log('Dark mode changed to:', this.darkMode());
// persist, sync to other components, etc.
});
}
}
From the parent's perspective, the model write is indistinguishable from any other signal write. Effects, computeds, and templates react identically.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
[(prop)] doesn't compile |
Child uses input() instead of model(), or the matching propChange output is missing |
Switch the child to model(), or pair input() with output<T>({ alias: 'propChange' }) |
| Parent doesn't see child writes | Bound the parent's variable as a literal instead of a signal | Use a signal for the parent variable: darkMode = signal(false) |
| Two-way binding feels laggy | The parent's effect that reacts to writes is doing something expensive | Move expensive work into a debounced effect or a resource() |
| Required model fails build | Parent didn't bind via [(prop)] |
Either provide the binding or make the model optional with a default |
model.required<T>() is undefined briefly |
Race in the constructor — model values are guaranteed when the template renders | Read inside effect() not constructor body |
What's next #
Lesson 3.4 covers debounced() — the new signal helper for search inputs and other rate-limited streams. Lessons 3.5 and 3.6 walk zoneless change detection in depth. Lesson 3.7 is the signal-vs-observable decision tree. Lesson 3.8 closes Module 3 with linkedSignal().
Try it yourself #
A color picker that two-way-binds a hex string:
import { Component, model, signal } from '@angular/core';
@Component({
selector: 'app-color-picker',
template: `
<input type="color" [value]="value()" (input)="value.set($any($event.target).value)" />
<code>{{ value() }}</code>
`,
})
export class ColorPickerComponent {
value = model('#dd0031');
}
@Component({
selector: 'app-demo',
imports: [ColorPickerComponent],
template: `
<app-color-picker [(value)]="color" />
<p [style.color]="color()">Hello in the picked color</p>
`,
})
export class DemoComponent {
color = signal('#dd0031');
}
Pick a color in the swatch — the <p> recolors instantly. Two-way binding, no manual (valueChange) handler.
get_best_practicesYes when the parent will use [()]. model() is purpose-built for two-way binding — it auto-generates the matching propChange emitter and packages the read/write into one signal. The only case for separate input() + output() is when the input semantics and the output semantics are genuinely different (e.g., input is a config object, output is an action event, not a value change). For “parent and child share this value,” reach for model() directly.Lesson 3.4 picks up debounced() — signal-native rate limiting.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


