Angular Deep Signal: Modeling State in Depth



This content originally appeared on DEV Community and was authored by Davide Passafaro

With the introduction of Signals in Angular, state management has become much clearer, more maintainable, and predictable.

Thanks to primitives like signal(), computed(), and effect(), you can model and derive data effectively while keeping dependencies between different parts of the state under control.

However, updating deep values of a Signal is not as straightforward, often requiring manual interventions on the parent state.

To address this need, the Angular team is currently developing a new Signal primitive that allows reactive access and modification of deep properties within another Signal: Deep Signal.

In this article, you’ll explore what Deep Signals are, how they work, and why they can help simplify the code in your Angular applications.

🎯 Accessing and Updating Deep State

Let’s consider an application that manages a user’s personal data.

This is a very common scenario, where the application state contains information such as the user’s first name, last name, and city of residence:

const userModel = signal({
  name: 'Davide',
  lastname: 'Passafaro',
  city: 'Rome'
  }
});

Thanks to computed(), you can easily derive values from this state, for example, to get the user’s first name and city:

const userName = computed(() => userModel().name);

const userCity = computed(() => userModel().city);

Now, suppose you want to allow the user to update their info via a form.

Thanks to ngModel, you can directly bind the form inputs to the Signals:

<p>Nome: <input type="text" [(ngModel)]="userName()"></p>

<p>Città: <input type="text" [(ngModel)]="userCity()"></p>

However, here arises a problem: values derived from computed() are read-only and therefore cannot be updated directly.

The ngModel directive, on the other hand, expects to be able to both read from and write to the associated model.

Linked Signal: a workaround, not exactly a solution

To update a deep property, you are forced to manually modify the parent state by creating a dedicated function.

Following the example, you can define an updateCity function like this:

function updateCity(newCity: string) {
  userModel.update(user => ({ ...user, city: newCity }));
}

At this point, you then connect this function to the form input, explicitly handling the change event:

<p>
  Città:
  <input
    type="text"
    [ngModel]="userCity()"
    (ngModelChange)="updateCity($event)"
  />
</p>

This solution, although functional, is not inherently reactive: it relies on an explicit call to the updateCity() function, leaving you responsible for maintaining state consistency.

And there are no guarantees that this will always happen systematically.

To work around this limitation, you can use linkedSignal(): a function that allows creating a derived Signal that is also writable.

By combining this with an effect(), you can achieve a sort of two-way synchronization between the derived value and the main state:

const userModel = signal({
  name: 'Davide',
  lastname: 'Passafaro',
  city: 'Rome'
  }
});

const userCity = linkedSignal(
  () => () => userModel().city
);

effect(() => {
  userModel.set({...userModel(), city: userCity())
});

In this scenario, the value of userCity() is derived from userModel(), but it can also be directly updated via userCity.set().

Your form becomes much more straightforward:

<p>Nome: <input type="text" [(ngModel)]="userName()"></p>

<p>Città: <input type="text" [(ngModel)]="userCity()"></p>

Every change to userCity() is intercepted by the effect(), which accordingly updates the main state. This way, you achieve a manual, homemade two-way synchronization.

Awesome!!!

Well, not exactly…

You do solve the problem, but at the cost of increased complexity:

  • Why do you use linkedSignal() instead of a simple computed()?
  • And why do you need that effect()?

These are all questions that future maintainers of the code, or even the future ourselves, might ask when faced with unclear code that doesn’t clearly explain the “why” behind the choices made.

For this reason, we need a more robust, natively bidirectional solution that doesn’t rely on manual workarounds and remains clear and explicit for anyone who reads or maintains the code in the future.

This is exactly where Deep Signals come into play.

🧬 The new Deep Signal primitive

With the new Deep Signal primitive, Angular introduces a robust and declarative solution for managing deep state.

A Deep Signal allows you to directly access and modify nested properties inside a Signal reactively and bidirectionally.

How to create a Deep Signal

As with other primitives, Angular provides a dedicated function, deepSignal(), to create a Deep Signal:

const userModel = signal({
  name: 'Davide',
  lastname: 'Passafaro',
  city: 'Rome'
  }
});

const userCity = deepSignal(userModel, 'city');

The deepSignal() function accepts two parameters: the parent Signal containing the state from which you want to extract a property, and the key that identifies that property within the structure, expressed either as a string or as a Signal containing a string.

The value returned by deepSignal() is effectively a bidirectional Signal: you can read its value just like with a normal computed(), but you can also update it directly using the set() or update() methods, without having to manually reconstruct the parent state.

In the example:

console.log(userCity()); // Prints "Rome"

userCity.set('Turin');   // Updates userModel().city

Being a Signal, it can then be used seamlessly within templates as well:

<p>Città: <input type="text" [(ngModel)]="userCity()"></p>

The ngModel directive is now fully compatible: every change to the input field will directly update the deep state inside userModel(), and vice versa.

This approach drastically reduces the necessary boilerplate and improves code readability and consistency, making access and updates to nested state portions more straightforward, explicit, and safe.

Performance of Deep Signals

Deep Signals are not only more convenient to use but also bring significant performance advantages.

As previously said, without them, updating a nested property requires updating the parent Signal:

const userModel = signal({
  name: 'Davide',
  lastname: 'Passafaro',
  city: 'Rome'
  }
});

userModel.set({...userModel(), city: 'Turin');

This means that even the smallest change, like updating just the city property, will trigger all effects and derived states that depend on any part of the parent userModel state.

Therefore, even those only interested in name or lastname will be notified, and every derived state will be recalculated. These will then detect that their data hasn’t actually changed and will skip further updates.

But still, the performance cost remains significant, especially in applications with complex states or many connected effects.

Deep Signal solves this problem through a more granular and targeted dependency management: it updates nested properties directly, notifying only those listening to that specific portion of the state.

This way, only the effects and derived states that care about the modified property are notified, drastically reducing the number of unnecessary notifications and recalculations, with clear performance benefits.

But that’s not all.

🧱 Structural Signals: When Structure Matters

The introduction of Deep Signals paves the way for another very useful Signal primitive: Structural Signals.

A Structural Signal is derived from a WritableSignal and returns its full value, similar to a Computed Signal, in this form:

const userModel = signal({
  name: 'Davide',
  lastname: 'Passafaro',
  city: 'Rome'
  }
});

const computedUserModal = computed(() => userModel());

The only significant difference is that a Structural Signal is not notified when changes occur through a Deep Signal.

It only detects changes when the parent Signal is updated directly.

By combining Structural Signals and Deep Signals, you can achieve more granular and intelligent control over updates, avoiding unnecessary triggers caused by new references that don’t actually change the content:

const userModel = signal({
  name: 'Davide',
  lastname: 'Passafaro',
  city: 'Rome'
});

// Structural Signal: returns the entire object
const userSnapshot = structuralSignal(userModel);

// Deep Signal: accesses a specific nested property
const userCity = deepSignal(userModel, 'city');

// Effect that reacts only to full structural changes
effect(() => {
  console.log('🧱 Structural update:', userSnapshot());
});

// Effect that reacts only to changes in the city property
effect(() => {
  console.log('🌍 City changed:', userCity());
});

// Update only the city: triggers only the Deep Signal effect
userCity.set('Milan');
// Output:
// 🌍 City changed: Milan

// Structural update (new object): triggers both effects
userModel.set({
  name: 'Davide',
  lastname: 'Passafaro',
  city: 'Turin'
});
// Output:
// 🌍 City changed: Turin
// 🧱 Structural update: { name: 'Davide', lastname: 'Passafaro', city: 'Turin' }

In this example, you can observe a precise behavior:

  • If you update only userCity through the Deep Signal, only those listening to that property (like the first effect()) will be notified.
  • If you replace the entire userModel object, structural observers (like the second effect()) will also be notified.

✅ Final Thoughts

The introduction of Deep Signals and Structural Signals is an exciting addition to the Angular ecosystem.

Being able to update derived state within a Signal directly is a major step forward for code readability, maintainability, and performance.

That said, something is still missing.

Currently, Deep Signals only support nested properties at a single level of depth. It’s not yet possible to reactively access deeper properties or state derived from more complex computations.

These limitations don’t take away from the value of the new primitives, they’re just a clear signal (badum-tss 🥁) of the direction Angular is heading. With each new release, it’s becoming more powerful and flexible.

All that’s left for us is to wait, ready to play with the next innovations. 🤓

Useful links for further reading

Thanks for reading! 🫶🏻

If you enjoyed the read and found it useful, leave a comment, like, or hit follow to stay in the loop. I’ll appreciate it! 👏

Don’t forget to share it with your community, tech friends, or anyone who might find it helpful! And follow me on LinkedIn, let’s connect! 👋😁


This content originally appeared on DEV Community and was authored by Davide Passafaro