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 simplecomputed()
? - 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 firsteffect()
) will be notified. - If you replace the entire
userModel
object, structural observers (like the secondeffect()
) 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