Angular model(): The Two-Way Binding Primitive, Desugared [2026]

Link copied
Angular model(): The Two-Way Binding Primitive, Desugared [2026]

Angular model(): The Two-Way Binding Primitive, Desugared [2026]

Angular Tutorial Module 3: Signals Lesson 3.3

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:

  1. The [prop] part sets the model's value (same as input())
  2. The (propChange) part receives emissions when the model is written from the child
  3. current = $event is 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>) — use model() 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.

YouShould I always prefer model() over input()+output() for two-way patterns?
Claude · used 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

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *