Understanding Two-Way Binding

Angular v17 marked a significant milestone for the framework when signals were officially promoted from developer preview to a stable reactivity primitive—with the exception of effects. Following this release, core team members presented a detailed roadmap during the #NgGlühwein meetup, outlining the incremental integration of signals across framework APIs where they provide the most value 👇:

Angular Reactivity Plan
Shortly thereafter, the team published the reactivity roadmap publicly, providing visibility into the incremental steps being taken to accomplish each objective from the plan above.

With signals stabilized in v17, attention turned to the next phase: Signal I/O. Although Angular v17.1 and v17.2 were classified as minor releases, they generated considerable excitement within the community by introducing new signal-based APIs:

  • input signals,
  • signal queries, and
  • model inputs.

The official blog post provides additional details on these APIs.

These APIs bring signals into nearly every component API surface, harnessing their reactive advantages and laying the groundwork for future signal-based components and zoneless change detection.

Both signal queries and signal inputs serve as reactive replacements for their decorator-based predecessors, while model inputs offer a reactive approach to two-way data binding in Angular.

Currently in developer preview, these APIs are expected to become the recommended approach once they reach production-ready status in a forthcoming version.

This article concentrates on model inputs as a reactive substitute for two-way binding, along with the unexpected additional feature that was introduced alongside them.

Let's get started 🚀

Two-Way Binding Basics

For quite some time, two-way binding has represented the most straightforward approach to parent-child component communication in Angular. It's commonly referred to as the banana-in-the-box—a memorable nickname for the template syntax that makes this possible. When developers discuss two-way binding in Angular, the widely-used ngModel form directive typically comes to mind:

@Component({
  selector: 'app-root',
  template: `
    // [(banana-in-the-box)]
               👇
    <input [(ngModel)]="name" />
  `,
})
export class AppComponent {
  name = '';
}
Enter fullscreen mode Exit fullscreen mode

At its core, two-way binding merges property binding with event binding, enabling bidirectional communication between components. Prior to model inputs, this communication pattern relied on decorator-based APIs—specifically the @Input and @Output decorators, as shown below 👇:

// child.component.ts
@Component({
  selector: 'app-child',
  ...
})
export class ChildComponent {
  @Input()
  counter: number = 0;

  @Output()
  counterChange = new EventEmitter<number>();

  changeValue(newValue: number) {
    this.counterChange.emit(newValue)
  }
}

// parent.component.ts
@Component({
  selector: 'app-parent',
  template: `
    // [(banana-in-the-box)]
                    👇
    <app-child [(counter)]="currentCount" />
  `,
})
export class ParentComponent {
  currentCount = 0;
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the input and output properties are defined independently, with no explicit connection between them. The compiler establishes the relationship automatically through a naming convention: the @Output property must use the Change naming pattern, where represents the name of the @Input property. Examining the compiled output for this implementation prior to model inputs reveals the following 👇:

_ParentComponent.cmp = /* @__PURE__ */ defineComponent({ type: _ParentComponent, selectors: [["app-parent"]], standalone: true, features: [StandaloneFeature], decls: 1, vars: 1, consts: [[3, "counter", "counterChange"]], template: function ParentComponent_Template(rf, ctx) {
  if (rf & 1) {
    elementStart(0, "app-child", 0);
 👉 listener("counterChange", function ParentComponent_Template_app_child_counterChange_0_listener($event) {
      return ctx.currentCount = $event;
    });
    elementEnd();
  }
  if (rf & 2) {  👇
    property("counter", ctx.currentCount);
  }
}, dependencies: [ChildComponent], styles: ["\n\n/*# sourceMappingURL=parent.component.css.map */"] });
Enter fullscreen mode Exit fullscreen mode

Two critical functions stand out: the property template instruction, which connects the currentCount field to the counter input property, and the listener template instruction, which establishes an event handler that updates the bound field's value upon execution.

Both participating components become aware when the bound value changes, yet other logic within the component cannot implicitly respond to this change without additional manual intervention:

// child.component.ts
@Component({
  selector: 'app-child',
  ...
})
export class ChildComponent {
  // using a setter
  _counter: number = 0;
  @Input()
  set counter(counter: number) { 👈
     this._counter = counter;
  }

  @Output()
  counterChange = new EventEmitter<number>();

  ...
}

// parent.component.ts
@Component({
  selector: 'app-parent',
  template: `
    // use standard property/event binding instead                    
    <app-child 
      [counter]="currentCount" 👈
      (counterChange)="onCounterChange($event)" /> 👈
  `,
})
export class ParentComponent {
  currentCount = 0;

  onCounterChange(value: number) {...}
}
Enter fullscreen mode Exit fullscreen mode

The child component uses a setter input to respond to value changes, while the parent replaces the banana-in-the-box syntax with explicit property and event bindings to capture and handle those changes.

The ngOnChanges lifecycle hook serves as an alternative to setter inputs.

This pattern has demonstrated its reliability over time and works without any issues.

Now, let's explore what model inputs bring to the table 🐱‍🏍.

Model Inputs

Model inputs were introduced in the Angular v17.2 minor release as part of the Angular team's ongoing effort to integrate signals into component I/O APIs. Similar to the decorator-based approach, model inputs enable bidirectional communication between parent and child components. What sets them apart is that they offer a functional, signal-based, reactive API while still leveraging the banana-in-the-box template syntax:

// child.component.ts
@Component({
  selector: 'app-child',
  ...
})
export class ChildComponent {
  counter= model(0); 👈

  changeValue(newValue: number) {
    this.counter.set(newValue) 👈
  }
}

// parent.component.ts
@Component({
  selector: 'app-parent',
  template: `      👇
    <app-child [(counter)]="currentCount" />
  `,
})
export class ParentComponent {
  currentCount = 0;
}
Enter fullscreen mode Exit fullscreen mode

With this approach, developers no longer need to manually define input and output properties. Instead, the model() function is called, and the Angular Compiler recognizes the API internally, generates the appropriate event and property bindings, and exposes a reactive public API for developers. The source code can be examined here:

https://github.com/angular/angular/blob/69948e1256daf969e060c602f950c3c92e4a5e43/packages/compiler-cli/src/transformers/jit_transforms/initializer_api_transforms/model_function.ts#L34

To satisfy the two-way binding contract, the function returns a writable signal, enabling value updates on the events side of the binding. The compiler-generated code appears as follows:

_ParentComponent.cmp = /* @__PURE__ */ defineComponent({ type: _ParentComponent, selectors: [["app-parent"]], standalone: true, features: [StandaloneFeature], decls: 3, vars: 1, consts: [[3, "counter", "counterChange"]], template: function ParentComponent_Template(rf, ctx) {
  if (rf & 1) {
    ... 👇
    twoWayListener("counterChange", function ParentComponent_Template_app_counter_counterChange_2_listener($event) {
      twoWayBindingSet(ctx.currentCount, $event) || (ctx.currentCount = $event);
      return $event;
    });  
  }
  if (rf & 2) {
    advance(2);
    twoWayProperty("counter", ctx.currentCount); 👈
  }
}, dependencies: [CounterComponent], styles: ["\n\n/*# sourceMappingURL=parent.component.css.map */"] });
Enter fullscreen mode Exit fullscreen mode

The same naming conventions are followed, but now they are managed by the compiler itself. However, unlike the previous implementation, two new template instructions were introduced to achieve this functionality: twoWayProperty and twoWayListener, along with twoWayBindingSet:

https://github.com/angular/angular/commit/3faf3e23d55b3e41cc43c4498393b01440f1cbb7
Examining the code behind these instructions reveals that they extend the existing listener and property template instructions with writable signal handling capabilities.

The twoWayProperty instruction reuses the logic from the property instruction but adds an extra verification step to determine whether the bound field is a writable signal, reading the field value accordingly:

// compiled code
twoWayProperty("counter", ctx.currentCount); 👈

// implementation
export function ɵɵtwoWayProperty<T>(
    propName: string, value: T|WritableSignal<T>,
    sanitizer?: SanitizerFn|null): typeof ɵɵtwoWayProperty {
  if (isWritableSignal(value)) { 👈
    value = value();
  }

  const lView = getLView();
  const bindingIndex = nextBindingIndex();
  if (bindingUpdated(lView, bindingIndex, value)) {
    const tView = getTView();
    const tNode = getSelectedTNode();
    elementPropertyInternal(
        tView, tNode, lView, propName, value, lView[RENDERER], sanitizer, false);
    ngDevMode && storePropertyBindingMetadata(tView.data, tNode, propName, bindingIndex);
  }

  return ɵɵtwoWayProperty;
}
Enter fullscreen mode Exit fullscreen mode

Conversely, the twoWayListener instruction adopts the same logic as the listener instruction, sets up the event, and registers an event listener. This listener doesn't simply update the bound field directly; it first checks whether the bound field is a writable signal (using the twoWayBindingSet instruction) and then updates the field value accordingly:

// compiled code
twoWayListener("counterChange", function ParentComponent_Template_app_counter_counterChange_2_listener($event) {
👉 twoWayBindingSet(ctx.currentCount, $event) || (ctx.currentCount = $event);
    return $event;
});

// implementation
export function ɵɵtwoWayBindingSet<T>(target: unknown, value: T): boolean {
  const canWrite = isWritableSignal(target);
  canWrite && target.set(value);
  return canWrite;
}

export function ɵɵtwoWayListener(
    eventName: string, listenerFn: (e?: any) => any): typeof ɵɵtwoWayListener {
  const lView = getLView<{}|null>();
  const tView = getTView();
  const tNode = getCurrentTNode()!;
👉listenerInternal(tView, lView, lView[RENDERER], tNode, eventName, listenerFn);
  return ɵɵtwoWayListener;
}
Enter fullscreen mode Exit fullscreen mode

Through this mechanism, these instructions expose the bound field to the child component as a signal — specifically a writable one — unlocking all the advantages that signals bring.

Upon closer inspection of the implementation, it becomes apparent that, alongside the model() function, a small feature was unexpectedly shipped. Interestingly, this was announced by Matthieu Riegler, one of the Angular stars, on X:

This section will delve into that feature in detail. Let's proceed 💪.

Signal Double Bindings

In its simplest form, this feature allows developers to bind a writable signal field in the template for two-way binding, going beyond JavaScript's primitive values:

// child.component.ts
@Component({
  selector: 'app-child',
  ...
})
export class ChildComponent {
  counter= model(0); 👈

  changeValue(newValue: number) {
    this.counter.set(newValue) 👈
  }
}

// parent.component.ts
@Component({
  selector: 'app-parent',
  template: `                  👇
    <app-child [(counter)]="currentCount" />
  `,
})
export class ParentComponent {
  currentCount = signal(0); 👈
}
Enter fullscreen mode Exit fullscreen mode

This means a writable signal works seamlessly with the banana-in-the-box syntax on any component that provides two-way binding.

The ngModel form directive mentioned earlier works perfectly in this context too. It provides another approach to working with form controls and enables reactivity when the control's value changes:

@Component({
  selector: 'app-root',
  template: `
    // [(banana-in-the-box)]
               👇
    <input [(ngModel)]="name" />
  `,
})
export class AppComponent {
  name = signal('inputs');

  👇
  nickName = computed(() => `model-${this.name()}`)

  constructor() {
    👉 effect(() => console.log(this.name()))
  }
}
Enter fullscreen mode Exit fullscreen mode

With this setup, developers gain a reactive means of sharing state between parent and child components, along with simplified ways to handle logic on both ends whenever the bound value changes.

It's important to note that, unlike the decorator-based approach, the expanded syntax for separate property and event bindings is not supported when binding a writable signal:

...
@Component({
  selector: 'app-parent',
  template: `
    <app-child 
      [counter]="currentCount" 👈 // Type 'WritableSignal<number>' is not 
      (counterChange)="onCounterChange($event)" /> assignable to type 'number'
  `,
})
export class ParentComponent {
  currentCount = signal(0);
}
Enter fullscreen mode Exit fullscreen mode

To understand why this limitation exists, let's examine the compiled code when binding a non-signal field:

_ParentComponent.cmp = /* @__PURE__ */ defineComponent({ type: _ParentComponent, selectors: [["app-parent"]], standalone: true, features: [StandaloneFeature], decls: 1, vars: 1, consts: [[3, "counter", "counterChange"]], template: function ParentComponent_Template(rf, ctx) {
  if (rf & 1) {
    elementStart(0, "app-child", 0);
 👉 listener("counterChange", function ParentComponent_Template_app_child_counterChange_0_listener($event) {
      return ctx.currentCount = $event;
    });
    elementEnd();
  }
  if (rf & 2) {  👇
    property("counter", ctx.currentCount);
  }
}, dependencies: [ChildComponent], styles: ["\n\n/*# sourceMappingURL=parent.component.css.map */"] });
Enter fullscreen mode Exit fullscreen mode

The root cause of this compile-time limitation lies in the fact that the standard template instructions — listener and property — are designed for standalone property and event binding in templates. They lack the additional logic required to handle writable signals, so they are restricted to binding non-signal fields only.

However, binding works when the signal value is explicitly unwrapped in the property binding, for the same reason it works with a non-signal field:

...
@Component({
  selector: 'app-parent',
  template: `
    <app-child 
      [counter]="currentCount()" 👈 // unboxing the value
      (counterChange)="onCounterChange($event)" />
  `,
})
export class ParentComponent {
  currentCount = signal(0);

  onCounterChange(newCount: number) { this.currentCount.set(newCount);}
}
Enter fullscreen mode Exit fullscreen mode

Hopefully this clarifies the behavior 🤗.

Conclusion

Angular continues to evolve. Signals, as a framework-wide feature, are progressively being woven into component APIs as part of a well-structured roadmap. This integration offers developers an improved authoring experience and fresh approaches to components and state-change reactivity. The v17.1 and v17.2 minor releases have largely completed the signal integration into component APIs by introducing model inputs, which enable signal-based two-way binding and thereby facilitate reactive state sharing between parent and child components. With the features introduced so far, the path toward signal-based components and zoneless change detection is being paved, giving the Angular community plenty of reasons to anticipate exciting future releases.

Special thanks to Enea Jahollari and Matthieu Riegler for their review.

Closing Thoughts

Thank you for reading — I trust you found it valuable. If it resonated with you, don’t hesitate to pass it along to peers and coworkers.

Got questions or ideas? Drop them in the comments — I’d love to hear from you.

To stay in the loop with upcoming pieces, follow me on Twitter, DEV, or Medium.