Refactoring a form component to use Angular Signals was the task I recently took on.

Here’s how the existing form component operates:

  • The form data originates from a reactive state service
  • That data is structured as an object
  • A clone of this object gets handed to the form component
  • The form component gets its data through a standard decorator-based Angular @Input
@Input({required: true})
user!: User;
Enter fullscreen mode Exit fullscreen mode
<div>
  <label for="firstName">First Name</label>
  <input id="firstName" name="firstName" [(ngModel)]="user.firstName" />
</div>
Enter fullscreen mode Exit fullscreen mode
  • Upon pressing the save button, the altered object is dispatched to the parent component using an Angular @Output
  • The parent component then refreshes the reactive state service

Across many of our projects, this pattern proves highly effective.

A demonstration of this approach is available in this StackBlitz, highlighting the core concept: Form with classic Angular @Input

Refactor to Signal Input

By leveraging Angular Signal Input, we gain the ability to make component inputs reactive. That sounds quite promising!

Notably, Signal Inputs are now the suggested standard for upcoming projects, as outlined in the Angular documentation:

Two-way bind a Signal Input object value with [(ngModel)] — figure 1

We’ll convert the standard Angular @Input into a Signal Input:

user = input.required<User>();
Enter fullscreen mode Exit fullscreen mode

Signal Input object value and [(ngModel)]

The Signal Input is provided with a User instance as its value. We then aim to use [(ngModel)] for two-way binding on the properties of that object.

The result is quite appealing visually 🎉!

<div>
  <label for="firstName">First Name</label>
  <input 
    id="firstName" 
    name="firstName" 
    [(ngModel)]="user().firstName" 
  />
</div>
Enter fullscreen mode Exit fullscreen mode

This StackBlitz demo appears to function as expected: Form with Signal Input - mutating Signal State

‼️ Danger Zone:

Hold on — we have just stepped into the danger zone... ☢️ ☣️ ⚠️

What is really going on under the hood?

  • When user() is invoked, the Input Signal is unwrapped, exposing the raw user object directly
  • The expression [(ngModel)]="user().firstName" will mutate that underlying user object — the very same object held inside the Signal — every time the input field value changes

☢️ Mutating the Signal object ☣️

Why should we avoid mutating Signal state? Because doing so completely sidesteps the public API designed for state updates. The only legitimate approach is to rely on the dedicated set or update methods.

Consider other developers who may want to derive new Signals from the user Signal using Angular's computed. Those computed Signals will remain untouched, since the user Signal has no awareness of the mutations happening to its object. That can lead to highly confusing behavior.

Signal Inputs are read-only

There is yet another reason why applying [(ngModel)] to a Signal Input feels off. By design, Signal Inputs are read-only. They lack a set or update method, meaning there is no supported path for altering Signal Input state programmatically.

Rescue

Let's make our getaway as quickly as we can 🚀. What options do we have?

Linked Signal

By using Linked Signal, we gain a writable user Signal that refreshes automatically whenever the user Signal Input is given a new value. At the same time, this Linked Signal supports direct updates via set and update.

Our Linked Signal is called editableUser...

export class UserDetailComponent {
  user = input.required<User>();
  editableUser = linkedSignal(() => this.user()); 

  updateEditableUser(v: Partial<User>) {
    this.editableUser.update(state => ({...state, ...v}))
  } 
}
Enter fullscreen mode Exit fullscreen mode

The updateEditableUser method was also brought in, relying on the public Signal API to mutate the Signal state—specifically, we invoke the update function of the Linked Signal.

<div>
  <label for="firstName">First Name</label>
  <input 
    id="firstName" 
    name="firstName" 
    [ngModel]="editableUser().firstName" 
    (ngModelChange)="updateEditableUser({firstName: $event})"
  />
</div>
Enter fullscreen mode Exit fullscreen mode

[(ngModel)] is now decomposed into [ngModel] paired with (ngModelChange).

  • [ngModel]="editableUser().firstName" refreshes the text input whenever the firstName property on the user object changes.
  • Each time the text input value changes, the updateEditableUser callback fires, which in turn updates the Linked Signal.

PROs

  • State updates rely on the public Signal API.
  • Linked Signal, being writable, is used for this purpose.
  • A dedicated method handles state changes explicitly.
  • No object cloning is required.

CONs

  • Boilerplate is present: setting up the Linked Signal, wiring ngModel, attaching ngModelChange, and writing the update method.
  • Linked Signal remains in developer preview as of Angular 19.

StackBlitz: Form with Signal Input - Linked Signal

Effect

Another route is to leverage Angular effect to watch for fresh values coming from the Signal input.
Upon receiving an updated value from the Input, we store that raw value into a local field on the class.

export class UserDetailComponent {
  _user = input.required<User>({alias: 'user'});
  user!: User;

  constructor() {
    effect(() => this.user = this._user())
  }
}
Enter fullscreen mode Exit fullscreen mode
<div>
  <label for="firstName">First Name</label>
  <input 
    id="firstName" 
    name="firstName" 
    [(ngModel)]="user.firstName" 
  />
</div>
Enter fullscreen mode Exit fullscreen mode

In the template, the raw object can still be mutated as it has always been.

Advantages

  • The template matches our original, traditional form component built with a standard Angular @Input.
  • We intentionally modify the raw object — there is no bypass of Signal APIs and no mutation of the read-only Signal Input state.

Drawbacks

  • user: User requires initialization: user: User = new User(); or the user!: User; definite assignment assertion is needed for TypeScript.
  • A clone is necessary (handled in the parent via the structuredClone pipe).
  • Naming gets tricky with _user for the Signal, 'user' as an alias, and user as the raw user object property.

StackBlitz: Form with Signal Input - Effect

@let approach

With @let, template variables become available.

We can leverage @let to retrieve the raw object from the user Signal Input. What's more, the clone operation is also possible using our structuredClone pipe.

@let userClone = user() | structuredClone;

<div>
  <label for="firstName">First Name</label>
  <input 
    id="firstName" 
    name="firstName" 
    [(ngModel)]="userClone.firstName" 
  />
</div>
Enter fullscreen mode Exit fullscreen mode

TypeScript:

export class UserDetailComponent {
  user = input.required<User>();
}
Enter fullscreen mode Exit fullscreen mode

Advantages

  • Requires minimal boilerplate and represents the tiniest change relative to the classic @Input-based form component
  • @let serves as the solitary spot where the transform happens: signals are unwrapped and cloned

Disadvantages

  • The input must be an object; primitive values fail. The Notes section elaborates on this constraint
  • Cloning is mandatory

Live demo: Signal Input form via @let

Notes

Objects vs Primitives

All earlier examples had the component Input receiving an object (the user data).
Both the Linked Signal and Effect strategies are compatible with Signal Inputs carrying primitives such as strings, booleans, or numbers.

Now examine the @let strategy:
@let variables cannot be reassigned... so no new values can be assigned to a @let variable, including through [(ngModel)]. Attempting this triggers the following compilation error:

Two-way bind a Signal Input object value with [(ngModel)] — figure 2

@let requires an object reference that we are allowed to modify in place.

Conclusion

Out of all the alternatives examined, @let offers the least ceremony and the most direct path to a working binding.

Both the effect and the linked signal variants do the job, but each carries extra wiring and configuration overhead.

Our team has not settled on a single recommendation yet, and the goal of this post has been to collect the evidence we need to compare the options fairly.

I hope this tour of the risks of mutating Signal payloads has been useful. Do you know of other ways out of this trap? I would love to read about them in the section below.

Thanks for reading!