Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Two-Way Binding with Signals for Complex Objects in Angular Forms

I'm seeking guidance on the best practice for using two-way binding with signals, particularly for complex objects in template-driven forms. Our team extensively uses template-driven forms and appreciates the simplicity of two-way binding for inputs. We're also excited about the new "model" input, which seems tailored for this purpose.

Currently, we use a viewModel signal to hold the state of our forms. These viewModels are typically objects, which appears to cause issues with two-way binding.

The problem:

  1. Two-way binding on an object property doesn't update the signal.
  2. There's no straightforward way to control this without abandoning two-way binding or using separate signals for each input, which becomes cumbersome for larger forms.

Example code: you can just paste the code here: https://angular.dev/playground

import {
    ChangeDetectionStrategy,
    Component,
    computed,
    effect,
    signal,
    WritableSignal
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { bootstrapApplication } from '@angular/platform-browser';

@Component({
    selector: 'app-root',
    standalone: true,
    changeDetection: ChangeDetectionStrategy.OnPush,
    imports: [FormsModule],
    template: `
        <!-- using two-way-binding with an object signal -->
        <!-- this will never update the signal "properly" -->
        <input type="text" [(ngModel)]="viewModel().name" />

        <!-- is this the recommended way? -->
        <!-- of course I could use primitive values for two-way-bindings only -->
        <!-- but with larger forms this seems not so nice -->
        <!-- especially when using multiple components with the model input -->
        <input
            type="text"
            [ngModel]="viewModel().name"
            (ngModelChange)="nameChange($event)"
        />

        <br />
        <!-- this will be updated because of the change detection that gets triggered by the input -->
        <!-- the signal never notifies, because the reference is not changed -->
        {{ viewModel().name }}

        <br />
        <button (click)="onClick()">click</button>
        computed: {{ testComputed().name }}
    `
})
export class CookieRecipe {
    viewModel: WritableSignal<{ name: string }> = signal({ name: 'startName' });

    testComputed = computed(() => {
        // this will not be triggered, because the reference of the signal value is not changed.
        console.warn('inside computed', this.viewModel());
        return this.viewModel();
    });

    constructor() {
        effect(() => {
            // this will not be triggered, because the reference of the signal value is not changed.
            console.warn('inside effect', this.viewModel());
        });
    }

    onClick() {
        console.warn('button clicked', this.viewModel());
        // the set here will change the reference and therefore the signal will update the effect, the computed and the view
        this.viewModel.set({ name: 'buttonClick' });
    }

    nameChange(name: string) {
        this.viewModel.set({ ...this.viewModel, name });
    }

    ngDoCheck() {
        console.warn('inside ngDoCheck');
    }
}

bootstrapApplication(CookieRecipe);

Questions:

  1. Is there a recommended approach to handle this scenario?
  2. Are there any plans to address this in future Angular releases?
  3. If not, what's the best current practice for managing complex form state with signals while maintaining the convenience of two-way binding?

Any insights or recommendations would be greatly appreciated. Thank you!

like image 442
Paul Avatar asked Sep 03 '25 03:09

Paul


2 Answers

One way is to take a bottom up approach.

  1. Make the individual properties as two-way binding models, this will handle the binding issue.
name = model('startName');
password = model('startNamePassword');
      <label for="name">Name:</label>
      <input type="text" [(ngModel)]="name"
          id="name"
          name="name" />
      <br/>
      <label for="password">Password:</label>
      <input
          type="text"
          id="password"
          name="password"
          [(ngModel)]="password"
      />
  1. Then we create a computed that collects all the individual emits from the individual models and gives you the computed value.
  viewModel: Signal<{ name: string; password: string }> = computed(() => {
    console.warn('inside computed');
    return {
      name: this.name(),
      password: this.password(),
    };
  });

Full Code:

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  signal,
  WritableSignal,
  Signal,
  model,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { bootstrapApplication } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  template: `
      <br/>
      <label for="name">Name:</label>
      <input type="text" [(ngModel)]="name"
          id="name"
          name="name" />
      <br/>
      <label for="password">Password:</label>
      <input
          type="text"
          id="password"
          name="password"
          [(ngModel)]="password"
      />
      <br />
      {{ viewModel().name }}
      <br />
      <button (click)="onClick()">click</button>
      computed: {{ viewModel().name }}
  `,
})
export class CookieRecipe {
  name = model('startName');
  password = model('startNamePassword');
  viewModel: Signal<{ name: string; password: string }> = computed(() => {
    console.warn('inside computed');
    return {
      name: this.name(),
      password: this.password(),
    };
  });

  constructor() {
    effect(() => {
      // this will not be triggered, because the reference of the signal value is not changed.
      console.warn('inside effect', this.viewModel());
    });
  }

  onClick() {
    console.warn('button clicked', this.viewModel());
  }

  ngDoCheck() {
    console.warn('inside ngDoCheck');
  }
}

bootstrapApplication(CookieRecipe);

Stackblitz Demo



All the below answers are my opinion based.

Is there a recommended approach to handle this scenario?

It's all upto your imagination, currently signals is in the phase of transitioning to forms support, so we have to wait.


Are there any plans to address this in future Angular releases?

As far as I know it's coming, we have to wait for the Team to release the standards for form signals


If not, what's the best current practice for managing complex form state with signals while maintaining the convenience of two-way binding?

Up to your imagination, you can use the building blocks of signals to achieve multiple ways of achieving the same thing, just until we get the new APIs for Angular signals.

like image 88
Naren Murali Avatar answered Sep 04 '25 23:09

Naren Murali


Signals have been created to greatly improve the change detection of the views, and also to get rid of zonejs in the future.

They're not made for replacing everything that exists.

Moreover, the model signal is just an input that can get its value updated manually, then again it's not something created to work with ngModel exclusively.

The form modules (let it be template driven or reactive) are made to work well within your views and trigger the changes when needed : they do not need signals to perform well. Adding signals like you are trying to do is just adding complexity and antipatterns that should not be implemented.

like image 25
MGX Avatar answered Sep 05 '25 00:09

MGX