[Complete] Sub-RFC 1: Signals for Angular Reactivity · angular/angular · Discussion #49684
Sub-RFC 1: Signals for Angular Reactivity
This discussion covers the choice of Signals as a new reactive primitive for Angular, and discusses several alternatives considered.
Why a new reactive primitive?
In a model-driven web application, one of the main jobs of the framework is synchronizing changes to the application's data model and the UI. We refer to this mechanism as reactivity, and every modern web framework has its own reactivity system.
Today, Angular's reactivity relies on the zone.js library. Reactivity with zone.js has both unique benefits and unique challenges, which are discussed in more detail below (see: Why not continue with zone.js?). Crucially, zone.js does not provide "fine-grained" information about changes in the model. Zone.js is only capable of notifying us when something might have happened in the application, and can give no information about what happened or what has changed.
In order to achieve our main goals (see: top-level RFC goals) we'll need to introduce a new reactivity model to Angular, with capabilities beyond what can be achieved with zone.js. This means adding a new primitive to the framework which can provide fine-grained information about model changes. A major design goal of Angular Signals is to make the integration of these two reactivity models as seamless and straightforward as possible, so developers can concentrate on shipping features instead of reasoning about how change detection is going to work in their applications.
Requirements for a new reactivity primitive
Before deciding on a new reactivity approach, we set out some requirements - properties that we would like the new reactivity solution to have:
- It must be able to notify Angular about model changes affecting individual components (per overall goals).
- It must provide synchronous access to the model, because template bindings must always have a current value.
- Reading values must be side-effect free.
- It must be _glitch fre_e: reading values should never return an inconsistent state.
- Dependency tracking should be ergonomic.
Discussion point 1A: are there other requirements we should consider?
Options considered
We experimented with a number of different alternatives, including:
- Improving zone.js
setState-style APIs- Signals
- RxJS
- Compiler-based reactivity
- Proxies
Choice of signal-based reactivity
During our research, one reactivity model stood out as clearly meeting our requirements while offering a very natural developer experience: signals. Signals are not a new idea in the framework space - Preact, Solid, and Vue all employ some version of this concept to great success.
So what is this primitive?
Signals
A signal is a wrapper around a value, which is capable of notifying interested consumers when that value changes. There are different kinds of signals.
Some signals can have their value directly updated via a mutation API. In our implementation, these are known as "writable" signals. Updates to the model are always done by changing one or more writable signals.
Because reading a signal is done through a getter rather than accessing a plain variable or value, signals are able to keep track of where they're being read. And because mutations are always done with the mutation API, signals can know when they're mutated and inform previous readers about the change.
Computed
A computed signal has a value which cannot be changed directly, but which is derived from the values of other signals.
Example of Signal and Computed
A good example of these two signal flavors working together is a counter numeric value and a boolean isEven flag which tracks whether the counter is even. We could model the counter as a writable signal and increment or decrement it directly. isEven is best modeled as a computed signal, since its value is determined by the value of the counter and should not be updated directly.
Effects
Signals are useful because they can notify interested consumers when they change. An operation that's performed when the signals it depends on are changed is called an "effect". For example, Angular will use an effect to update a component's UI whenever any signals read within that component's template are changed.
Automatic Dependency Tracking
A cornerstone of our signal system design is that when computed signals and effects run, they keep track of which signals were read as part of the computation or effect function. Knowing the dependencies allows the signal system to re-run the computation or effect function automatically whenever any signal dependencies change.
Why is this a good fit for Angular?
Signals as a reactive primitive meet all of the above requirements:
- The framework can track which signals are accessed in a given template, giving us fine-grained information about which components are affected by a given change to the model.
- Signals allow synchronous access to their values.
- Reading signals can't trigger side effects.
- Modern signal implementations are glitch-free and don't expose inconsistent states to the user.
- Signal implementations can track dependencies automatically.
- Signals can be used anywhere, not just in components, which plays well with Angular's dependency injection system.
Additionally, we see a number of additional benefits of signals:
- Computed signals can be lazy and only recompute intermediate values on demand.
- There are relatively few concepts for developers to learn.
- They are easily composed with other reactivity systems, including RxJS and Angular's current zone-based reactivity.
Of all the alternatives explored (see below), our conclusion is that signals are the best fit for Angular and will allow us to meet (and in some cases exceed) our overall goals.
Integration with Signals
So how will Angular make use of this new reactive primitive?
Signals will affect many areas of the framework, especially including:
- Data flow and model synchronization
- The change detection process
- Component lifecycle
- Reactive values produced by the framework (ex.
@Inputs)
In this section, we'll dig into how signals will fit into Angular's component model.
Zones are here for the foreseeable future
It's important to recognize that we won't be able to replace zone.js with signals transparently under the hood. Zones and signals are based on fundamentally different assumptions about how data flows through an application, and a component written with zone.js in mind will likely not function without it. We may be able to provide some assistance in migration through tooling, but it will not be completely automatic.
That means for most applications, the new signal-based reactivity will need to coexist and interoperate with existing zone-based reactivity for the foreseeable future.
Signal-based components
We're introducing a new type of signal-based component, which uses signals for its reactivity and rendering. Both the new signal component and existing zone-based components will be able to use and interact with each other in the same application.
Using components as the boundary between reactivity systems will allow application developers to gradually opt-in to signal reactivity in an existing application, and allow library authors to convert their libraries to use signals in a backwards-compatible way.
Note: while this section is written mainly talking about components, almost all of the topics here apply equally to directives.
Local change detection
One significant new capability that signal-based components will have is local change detection. Unlike zone.js, signals give fine-grained information about which parts of the model have changed, signal-based components do not participate in the global change detection. Instead, Angular understands which signals are used in different parts of the component's template, and only synchronizes that component with the DOM when a signal changes.
This is the golden rule of signal components: change detection for a component will be scheduled when and only when a signal read in the template notifies Angular that it has changed.
In fact, in our current design this change detection will happen independently for each view within a component.
Aside: Angular's "View" concept
"Views" in Angular are static fragments of templates - sets of known UI elements, directives, and child components. Views are composed together to create templates that can express conditional or repeated sections of UIs.
For example, the following component template is a single view:
<div> <label>Who: <input name="who"></label> <label>What: <input name="what"></label> </div>
Whereas this template has two views - the outer DOM with "Who" and "What", and an embedded view which is conditionally shown which contains "Why":
<div> <label>Who: <input name="who"></label> <label>What: <input name="what"></label> <ng-container *ngIf="showWhy"> <label>Why: <input name="why"></label> </ng-container> </div>
Each branch of an NgIf or NgSwitchCase and each row of an NgFor are examples of independent views in Angular.
Granularity of local change detection
The choice of per-view change detection is an important one, as there's an entire spectrum of granularity with which we could track changes to the model and update the DOM:
- Entire application: processing the whole application at once (what zone.js does, with the exception of
OnPushoptimizations). - Component tree: traversing individual subtrees (an individual component and its children)
- Individual components: checking a component (including all of its views) but not its children
- View: checking each view individually
- DOM element: updating all the bindings for each DOM element individually (text nodes, elements, etc. SolidJS works at this level)
- Binding: updating each DOM binding individually
So why check per-view and not update individual bindings? Would that not be faster/more efficient? The tradeoffs here are not so clear-cut, and while we are interested in exploring designs with more granular updates in the future, there are a few reasons why we believe per-view checking is the best option for Angular's signals today.
- Dependency tracking has overhead.
Setting up the dependency graph is not free. Our signal library is fast, but the more granular the tracking, the more nodes in the graph need to be allocated and bookkept. This not only takes time, but consumes memory. We have to weigh this cost against the potential benefits of increased granularity.
- Checking individual views delivers most of the benefit already.
Views are typically small fragments of UI with a manageable number of bindings. The cost to evaluate and change detect a small set of bindings is already very low (remember, Angular does this today for entire applications).
The most expensive components to process with change detection are often those with large, repeated, highly dynamic structures, such as data tables with hundreds or thousands of rows. Such components are naturally already broken down into many individual views (remember, each row is at least one view, maybe more). With view-based change detection and a properly structured model, signals can already be used to drive updates only to one row of the table without needing to process other unchanged rows.
- View-based change detection with signals composes nicely with zone-based change detection.
The operation of running change detection against a view is already the main primitive on which the existing zone-based whole-application change detection system is built. Using the same granularity for signals makes the interoperability story much more straightforward, and allows the two reactivity models to share most of their underlying implementation code. This will allow us to introduce signal reactivity without greatly impacting bundle size.
@inputs are signals
In signal-based components, inputs will be signals! This choice is directly aimed at the goal of having a clear, unified model for how data flows through an application.
Signal-based inputs have a major impact on data flow, because they work as computed signals, and not change-detected expressions.
Aside: Inputs in Angular Today
To understand the difference in how signal-based inputs work, it's useful to consider in more detail how inputs work in zone-based applications today.
In a zone-based application today, inputs are set during change detection. Let's say the HomePageCmp has the following template:
<user-profile [userData]="authService.loggedInUser.data" />
Suppose the loggedInUser changes. zone.js will notice something happened but doesn't know what specifically changed, and will trigger change detection for the whole application. Change detection will process the HomePageCmp and re-evaluate the binding to [userData]: the expression authService.loggedInUser.data. It will set the UserProfileCmp.userData field to the new value, before descending into the UserProfileCmp and evaluating its template, which might make use of the userData.
Signal-based inputs as computations
If HomePageCmp and UserProfileCmp were signal based components, the userData input would function very differently. Of course, the binding would have to use a signal for the loggedInUser:
<user-profile [userData]="authService.loggedInUser().data" />
When HomePageCmp is first created, and is creating its UserProfileCmp child, Angular will create a computed signal for the [userData] binding expression: computed(() => authService.loggedInUser().data). This derived signal is then provided as the value for the UserProfileCmp's userData input.
This has huge implications:
Signal inputs resolve before change detection
Updates to the userData input signal happen before change detection, not during.
This is the major difference from zone-based component inputs. With signals, the "model to model synchronization" of propagating changes to userData resolves before Angular starts any UI synchronization.
This eliminates an entire category of data flow challenges faced by zone-based applications:
ExpressionChangedAfterItHasBeenCheckederrors are no longer a risk, because the model is fully synchronized before it gets checked. (A non-deterministic model can still cause such errors, but that's a different problem)- Data can flow independently of the UI hierarchy, instead of being forced to only flow from parent to child. There's no need for
setTimeoutorPromise.resolveor other tricks.
Input bindings don't trigger local change detection in the binder
Because userData's binding is provided as a computed signal, none of the views in the HomePageCmp depend on its value. Per the golden rule of signal-based change detection, none of its views will be change detected.
Input bindings don't automatically trigger local change detection in the receiver
Just because UserProfileCmp receives userData as a computed signal does not mean that any of its views will be change detected when userData changes. Per the golden rule, UserProfileCmp will only be change detected if it reads the userData input signal somewhere in its template.
In other words, inputs which aren't actually used in templates don't trigger any change detection in signal-based components.
- When
loggedInUserchanges,HomePageCmpdoes not need to change detect any views.
That's because it never actually reads the loggedInUser() value for itself, it only forwards it as a computation to its child component.
Queries are signals
Like inputs, view and child queries are an example of the framework "producing" a reactive value which represents some aspect of the component model. In signal-based components, queries will also be exposed as signals. This allows components to naturally react to queries changing via computed properties or effects, just like with inputs.
Mixing Signal and Zone Components
It's possible to freely mix signal and non-signal components in the same application. As long as the golden rule of signal component is observed, change detection should function correctly even across these boundaries.
When a signal component provides an input binding to a non-signal component, signal semantics are used to detect when the binding changes, and set the input of the non-signal component, mark it for check if necessary, and run ngOnChanges if needed as well.
When a non-signal component binds to the input of a signal component, a similar conversion happens. During change detection of the non-signal component, the binding is evaluated and, if the value has changed, the input signal passed to the child component is updated.
Caveat: non-signal API issues
Sometimes directives expose APIs for their consumers, via local references or the DI system. For example, NgModel exposes its current value as a public property, accessible via local refs:
<input ngModel #in="ngModel"> <p>You typed: {{ in.value }}</p>
Such APIs may be problematic in signal components, since in.value isn't a signal and can't be used to trigger change detection for signal components, even though it changes as the user types.
Note that in zone-based components though, relying on data from a child like this is hugely problematic anyways. If the reflection of the current value is moved above the <input> declaration, an ExpressionChangedAfterItHasBeenChecked error will result.
For Angular-authored directives, we will revisit their APIs and adapt them as needed to work with signal-based change detection. We would expect community libraries to eventually do the same, but this process will take time.
Zoneless Angular
With signals, it will be possible to build an Angular application without zone.js. Rather than relying on zones for change detection to function, signal-only applications will schedule change detection of individual views directly (likely via requestAnimationFrame or some other browser primitive).
In such an application, it would be an error to attempt to use a zone-based component.
Frequently Asked Questions
Architecture
Will signals lead to zone.js being optional?
Yes, this is one of the driving motivations that led the team to signals.
If you write an application exclusively composed of signal-based components, you will be able to remove the dependency on zone.js.
Is zone-based change detection deprecated or removed?
No, zone-based change detection will remain supported for the foreseeable future. Components that depend on zone.js and/or signals can be freely mixed in one application. An application would have to fully track its model in signals to completely remove dependency on zone.js.
Are we going to have another change detection strategy?
Many applications and teams recommend the OnPush change detection strategy to improve performance. Signal-based components have "OnPush on steroids" baked-in.
In the design proposed here only components that read an updated signal will have to be change-detected. None of its parents and none of its children. One and only one component that needs to display changed data.
Signal based components have all the benefits of the OnPush strategy (and more!) without any of its drawbacks - by default. In this sense we actually remove the very concept of change detection strategy..
What about state management with signals?
Signals are a great primitive for managing state and data flow, but we fully expect that more sophisticated stores and state management solutions will emerge. Dedicated state management libraries can address many development concerns, such as granularity of data, collocating signals with custom update methods, managing effects, composing "stores" etc.
We have no plans to develop a first-party state management solution in Angular itself. The broader Angular community, though, possesses a wealth of knowledge and experience in this area. Some major state management libraries have already started to explore incorporating signals:
Why not use RxJS Observable as the reactive primitive?
Observables are a very powerful and flexible primitive for expressing computation in a functional and reactive way. They're used throughout the Angular ecosystem already, often to achieve more fine-grained reactivity than zone.js provides out of the box. For this reason, Observables do seem at first glance to be a natural fit for a new reactivity system for Angular.
We did extensively explore this option, and our conclusion is that while Observables are a powerful primitive, they are a poor fit for template rendering specifically.
Observables are naturally asynchronous
Observables can be used to model both synchronous and asynchronous data flow. However, they don't distinguish these two cases in their API - any Observable might be synchronous, or it might be asynchronous. This duality makes them flexible, but creates issues when using them to model template reactivity.
In an Angular template, bindings always have a current value. For example the property binding:
always provides a value to display inside the <input>. Angular doesn't need to consider the case where the [value] binding doesn't have a value to set yet. If this binding were driven by an Observable instead (let's say name$), then we would lose this guarantee: the Observable may not provide a value synchronously on subscription. In fact it may never produce a value. Angular would therefore need to deal with any binding potentially being in this "pending" state.
This is partly the problem that the async pipe solves. The async pipe always has a value, even if the Observable it's subscribed to has not yet produced a value. Until it does, the async pipe will return null as its value. This behavior is known to be somewhat annoying (requiring patterns like *ngIf="data$ | async as data" to guard against the nulls) and would become even more so if every binding behaved that way, if every input were also forced to be nullable, etc. And null is not the right choice as a "default" for all bindings (text bindings for example would probably want to show an empty string).
We could instead enforce that every Observable used in a template must be synchronous, but this is also an arguably poor experience. Since there is no way to enforce synchronicity through typings, it would need to be enforced with a runtime exception. This could lead to subtle issues if asynchronicity is accidentally introduced deep within an Observable chain, especially if it's only conditionally asynchronous.
Observables are not side-effect free
Another observation (pun intended) is that Observables are often cold and side-effectful. That is, every subscription may potentially trigger arbitrary behavior, up to and including RPCs to backend APIs. This is how HttpClient works: userData$ = httpClient.get('/user') produces an Observable that, when subscribed, will make the backend request to fetch user data.
This is again part of what makes RxJS such a powerful library. The ability to execute data fetching and other expensive tasks on demand allows for very declarative expression of complex data flows, debouncing, retry logic, etc. However, it also leads to issues when using them to drive template rendering. For example, using userData$ in a template will work as expected:
<p>First name: {{ (userData$ | async)?.firstName }}</p>
but if we also try to show the last name:
<p>First name: {{ (userData$ | async)?.firstName }}</p> <p>Last name: {{ (userData$ | async)?.lastName }}</p>
then we've accidentally caused two backend requests instead of one. This can be addressed with the share or shareReplay operators, but there is no way to enforce this, especially across different components using the same Observable.
RxJS is not glitch-free
This is a much more subtle but deeply important point.
In reactive systems, a glitch occurs when a calculation or reaction observes an intermediate, inconsistent state. As a simple example, consider the counter and isEven example from before. If we write code that logs the message ${counter} is ${isEven ? 'even' : 'odd'} whenever either value updates, then as the counter increments from 0 to 1 we should observe the messages "0 is even" and "1 is odd", but never the message "1 is even". That is, isEven should always be in sync with counter.
RxJS does not provide this guarantee. It's easy to craft an example which shows this behavior:
import { BehaviorSubject, combineLatest, map } from 'rxjs'; const counter$ = new BehaviorSubject(0); const isEven$ = counter$.pipe(map((value) => value % 2 === 0)); const message$ = combineLatest( [counter$, isEven$], (counter, isEven) => `${counter} is ${isEven ? 'even' : 'odd'}` ); message$.subscribe(console.log); counter$.next(1);
Running this example gives the output which shows the glitch:
0 is even
1 is even
1 is odd
In asynchronous RxJS code, glitches are not typically an issue because async operations naturally resolve at different times. Most template operations, however, are synchronous, and inconsistent intermediate results can have drastic consequences for the UI. For example, an NgIf may become true before the data is actually ready, which could result in reading undefined properties. Or NgFor may briefly observe an empty array and prematurely destroy all rows in a table only to recreate them when the real array arrives, leading to an expensive rerendering.
Conclusion
Because of these incompatibilities with using Observables for template rendering, we decided against using them as the basis for Angular's reactivity. We believe their strength lies in orchestrating complex asynchronous operations, where their flexibility is a big advantage and where computational glitches are not as much of an issue. Therefore, it makes sense to instead invest in interoperability between Observables and the new signal reactivity system. This allows RxJS to be used where it shines, without forcing it to also fit the needs of template rendering.
Signals and RxJS
What about Angular APIs that currently use RxJS?
There are a number of Angular APIs that use RxJS in their API signature(ex. HttpClient, EventEmitter, Forms, Router etc.).
The general plan is to review Angular API surface and selectively convert RxJS usage and / or add signals usage when appropriate. This will not be automatic conversion, though, as RxJS-based APIs might represent two distinct concepts:
- value changing over time - those are perfect candidates for signals. Form validation status or router's params are great examples here;
- stream of events - those conceptually don't match signals and would not be converted.
HttpClientandEventEmitterare perfect examples of APIs that should not be expressed as signals.
In any case we will come back to the API surface review after the initial signals rollout. Any changes will be done in a backward-compatible way and subject to RFCs.
Should I use signals and/or RxJS? What is the difference and role of those primitives?
Signals would be the reactive primitive deeply integrated in the framework and thus a go-to solution for state synchronization needs. This is the first "reactive tool" that people should be reaching out for.
RxJS shines when it comes to orchestrating complex asynchronous operations. Observables power and flexibility comes at a certain cost, mostly related to the learning curve.
We definitively see use-cases where RxJS shines and solves problems in a very elegant way. This is why we are investing in a good interoperability story between signals and RxJS Observables.
Having said this, we would like to see the World where RxJS is adopted by teams as a choice for specific use-cases rather than as a mandated or default "best practice". There are many applications that can be written without complex asynchronous orchestration and we would like to encourage people to experiment with different patterns.
Introducing signals gives us a chance to try different approaches and evolve new best practices.
Why not evolve zone.js instead?
There are a few reasons why we don't think we can achieve our goals by building a better, faster version of zone.js.
The Zone approach isn't scalable.
Zone is based on monkey-patching platform APIs like setTimeout, Promise.then, and addEventListener. This lets Angular know when something has happened in the browser, and we can then schedule change detection to synchronize the UI with any potential model changes.
This approach has not scaled well. zone.js must be loaded before the application starts in order to patch its target APIs. This patching operation is not free, both in terms of bytes downloaded and milliseconds of load time. zone.js also has no idea which APIs the application will use, and must patch them just in case. As browsers add more and more APIs, the cost of this patching grows, as does the cost of maintaining zone.js.
Another major issue is that some platform APIs, such as async / await, are not monkey-patchable at all, requiring awkward workarounds or forced down-leveling.
While we can work to optimize zone.js and reduce these costs, we will never be able to eliminate them completely.
Zones are the root cause of many common pain points in Angular.
In looking back at 7+ years of Angular history and feedback, we've identified zone.js as a common root cause behind many developer experience or performance challenges.
The infamous ExpressionChangedAfterItHasBeenChecked error is often the result of violating Angular's assumptions of how data will flow in your application. Angular assumes that your data will flow from top to bottom along the view hierarchy. This assumption is rooted in how zone.js separates model updates from UI reconciliation, resulting in the potential for "unsafe" model updates.
Another issue we've noted is that zone.js can easily overreact to activity in an application, resulting in many unnecessary change detections. Often this results from third-party advertising, marketing, or analytics scripts being initialized in a way that runs their many timers or other actions in the Angular zone. In the worst cases, we've seen applications which do over 1,000+ change detections at startup. It's always possible to write code with performance problems, but zone.js makes it easy to create such issues unintentionally.
Developers are opting in to more fine-grained reactivity today
Many developers today use state management patterns or libraries that architect their data in a way where they can tell Angular exactly what changed. This usually takes the form of making components OnPush and using Observables together with the async pipe to mark components for check when state they depend on changes. Angular is capable of operating in this mode, but still relies on zone.js for top-down change detection. Thus, developers get some, but not all of the benefits of fine-grained reactivity, and still have to deal with the drawbacks of zone.js.