DEV Community

Cover image for Model Inputs: Reactive Two-Way Binding
Ilir Beqiri for This is Angular

Posted on

Model Inputs: Reactive Two-Way Binding

In Angular v17, the Angular team announced that signals graduated from the developer preview (except effects) as a stable and new reactivity primitive in the framework. Shortly after that, a high-level plan was published by the core members of the Angular team on a meetup (#NgGlühwein), showing the concrete, incremental steps that will follow to gradually integrate signals throughout the framework APIs that they best fit in and take advantage of 👇:

Angular Reactivity Plan
Then, not a while later, the team revealed publicly the reactivity roadmap, where you can track all the tiny steps the team is taking to fulfill each of the defined points shown in the plan above.

With the signals already stable on v17, the focus shifted to the second step in the plan: Signal I/O. The next two minor releases, Angular v17.1, and v17.2 released a few weeks ago, beyond the fact that they were minor releases, they have made a big splash in the community with the new signal APIs introduced in the framework:

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

You can read more about these APIs in the official blog post.

Those APIs integrate signals in nearly all component "APIs", leveraging the benefits they offer, thus paving the path to future signal-based components and zoneless change detection.

Both, signal queries and signal inputs, as per the name, provide a reactive, alternative API to decorator-based counterparts, and model inputs provide a reactive, alternative API for two-way data binding in Angular.

Currently, in the developer preview, those APIs will be recommended to be used once they are promoted to production-ready APIs in an upcoming version.

In this article, I will focus on model inputs, as a reactive alternative of two-way binding, and get to know the bonus feature introduced unexpectedly alongside them.

Let's dive in 🚀

Two-Way Binding

For many years now, two-way binding has been the simplest form of parent-child component communication in Angular. It is also widely known as banana-in-the-box - an illustrative name for the template syntactic sugar. Most of the time when we talk about two-way binding in Angular, we all think about the popular ngModel form directive:

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

In a nutshell, two-way binding is a combination of property and event binding for bidirectional communication between components. Before model inputs, this two-way communication was possible through the decorator-based APIs, specifically @Input and @Output decorators as 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 code above, you can see that we only define the respective input and output properties but do not define an explicit relationship between them. The compiler is smart enough to detect the relationship through a naming convention: the @Output property must use the Change naming pattern where the is the name of the @Input property. If we check the compiled code for the implementation above before model inputs, it looks like the below 👇:

_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

You can barely notice two functions of great importance: the property template instruction which binds the currentCount field to the counter input property, and the listener template instruction sets up an event and registers a listener which when executed updates the bound field's value.

This way, components on both ends are aware when the bound value changes but other logic in the component can't implicitly react to this change unless some manual work is done there:

// 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

As you can see, on the child end, a setter input is used to react whenever the bound value changes, and on the parent end, the banana-in-the-box syntax is replaced with the standard property and event binding instead so the parent will be able to listen for the value change and react accordingly.

The ngOnChanges lifecycle hook is an alternative to setter inputs.

And… there is no issue with this code, it has been proven to work over time, and serve very well.

Next, let's see what we get with model inputs 🐱‍🏍.

Model Inputs

Introduced in the Angular v17.2 minor release, as an ongoing effort of the Angular team to integrate signals to component I/O APIs, similar to decorator-based APIs, model inputs enable bidirectional communication between parent and child components. A key difference is that they provide a functional, signal-based, reactive API but still use the banana-in-the-box syntactic sugar in the template:

// 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

Now, instead of the developer defining the input and output properties, we call the model() function. It's the Angular Compiler that recognizes the API internally and defines the event and property bindings thus providing developers with a reactive public API for use. You can check the source code here 👇:

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

To fulfill the two-way binding contract, the function returns a writable signal, thus enabling value updates on the events side of the binding. The generated compiler code looks like this 👇:

_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 convention/pattern is followed as before but now by the compiler itself. But in difference with the previous implementation, to achieve this functionality, a pair of new template instructions were introduced: twoWayProperty, and twoWayListener with twoWayBindingSet:

https://github.com/angular/angular/commit/3faf3e23d55b3e41cc43c4498393b01440f1cbb7
If you check the code behind these instructions, one can see they enhance the functionality of existing, listener, and property template instructions, with writable signal handling capabilities.

The twoWayProperty instruction uses the same logic as the property instruction but also does an extra check if the bound field is a writable signal or not, and reads 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

On the other hand, the twoWayListener instruction uses the same logic as the listener instruction, sets up the event, and registers an event listener. This listener itself, does not just update the bound field directly but does an extra check if the bound field is a writable signal or not (using 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

So, in this way, these instructions enable exposing the bound field to the child component as a signal, specifically a writable signal, thus opening doors for all the benefits that signals provide.

But… if you stop and reason carefully about the implementation, you can find that alongside the model() function, surprisingly, a featurette (read: small feature) was shipped. What is interesting is that it was announced by one of the Angular stars, Matthieu Riegler in X 🚀:

This is what the next section is all about. Let's go 💪.

Signal Double Bindings

In the simplest form, this means that: besides JavaScript's primitive values, developers can now bind a writable signal field in the template for two-way binding as follows:

// 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

Now, this leads to the point that a writable signal will work with the banana-in-the-box syntax on any component providing two-way binding.

Remember the ngModel form directive I mentioned earlier? It works perfectly fine there too. It offers another alternative to working with form controls and ways to react 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 in place, we have a reactive way of sharing the state between parent and child components, and easier ways to run logic on both ends whenever the bound value changes.

A thing worth noting here is that, unlike the decorator-based approach, the expanded syntax of property/event binding 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 happens, let's see 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 reason behind this compile-time issue is that the classic template instructions, listener, and property are used for the standalone property and event binding in the template, having no extra logic for handling writable signals, thus restricted to using only non-signal fields for binding.

But it works if you unbox the signal value when property binding for the same reason when binding 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

Hope that this is all clear 🤗.

Conclusion

Angular is evolving. Signals, as a framework-wide feature, are gradually being integrated into component APIs, as a part of a well-prepared plan, thus offering developers a better experience and new ways of authoring components, and reacting to state changes. The last two minor releases, v17.1 and v17.2, nearly completed the signal integration to component APIs by model inputs introducing signal-based two-way binding hence reactively sharing state between parent and child components. With currently introduced features, paving the path toward signal-based components, and zoneless change detection, the Angular community has reason to look forward to exciting future releases.

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

Thanks for reading!

I hope you enjoyed it 🙌. If you liked the article please feel free to share it with your friends and colleagues.

For any questions or suggestions, feel free to comment below 👇.

If this article is interesting and useful to you, and you don't want to miss future articles, follow me at @lilbeqiri, dev.to, or Medium. 📖

Top comments (3)

Collapse
 
jangelodev profile image
João Angelo

Hi Ilir Beqiri ,
Your tips are very useful
Thanks for sharing

Collapse
 
lilbeqiri profile image
Ilir Beqiri

Thanks a lot ❤️

Collapse
 
railsstudent profile image
Connie Leung

Excellent article. Superpower to understand the codebase of Angular