[Complete] Sub-RFC 2: Signal APIs #49683
Replies: 41 comments 271 replies
-
In my ~6 years of using Signals, they have always been with the I wonder if it would be worth it to just expose both on the interface? That way we could use If it came down to it, I think I would prefer only having |
Beta Was this translation helpful? Give feedback.
-
awesome work from the Angular team, this is amazing to see :)
|
Beta Was this translation helpful? Give feedback.
-
My vote goes for "Signal / WritableSignal".
Yes.
Edited: forget about it, I just noticed that
Yes. It's also easy to mark as "non-writeable" using TS. |
Beta Was this translation helpful? Give feedback.
-
Wasn't |
Beta Was this translation helpful? Give feedback.
-
Then why are you returning WriteableSignal from |
Beta Was this translation helpful? Give feedback.
-
The type narrowing in templates issue is the first part of reading the RFCs so far that has given me real pause. Gut feel without having worked with this in detail is that That said, you allude to "some workarounds" that might avoid the issue while keeping the getter syntax? Perhaps you could go into more detail on that? Finally, big thanks to you and everyone else on the team for the hard work on this process :) |
Beta Was this translation helpful? Give feedback.
-
What about a deep-set with a dotted path argument ?
|
Beta Was this translation helpful? Give feedback.
-
I'd like to point out an edge case with this.query = signal('');
this.fetchEffect = effect(async () => {
const response = await fetch('/users?query=' + this.query())
this.users.set(await response.json());
})) The example above does not compile as The workaround is to use Even if the types are easily fixable, the current design uses the return value of the effect as the cleanup function, which is incompatible with the typical use-case where you want to abort a request: this.fetchEffect = effect(async () => {
const controller = new AbortController();
const response = await fetch('/users?query=' + this.query())
this.users.set(await response.json());
return () => controller.abort();
})) FWIW Vue uses another approach, and the function passed to watchEffect(async (onCleanup) => {
const controller = new AbortController();
const response = await fetch('/users?query=' + query.value)
users.value = await response.json();
onCleanup(() => controller.abort())
}) |
Beta Was this translation helpful? Give feedback.
-
Instead of items.mutate(value => value.push(item));
// could be replaced by
items().push(item);
items.set(items, {skipEqualityChecks: true});
// or even by
items.update(value => {
value.push(item)
return value;
}, {skipEqualityChecks: true}); While this is a bit more verbose, it is more explicit and can be wrapped in a IMO, an option like |
Beta Was this translation helpful? Give feedback.
-
As mentioned by @JeanMeche in a discussion above, it would be nice to have an const name = signal('foo');
const readOnlyName = name.asReadonly(); // no `set()`, `update()` or `mutate()` functions
// is better and more efficient than
const name = signal('foo');
const readOnlyName = compute(() => name()); |
Beta Was this translation helpful? Give feedback.
-
We have an in-house implementation of signals that has widespread usage in our codebase, currently we integrate it with Angular through an
These invariants would make it unlikely that we would be able to just call Angular's functions, like I wonder if Angular might consider allowing the Appendix: const signalMap = new WeakMap<LucidSignal<any>, AngularSignal<any>>();
@Pipe({name: 'toAngularSignal', pure: true})
export class ToAngularSignal implements PipeTransform {
public transform<T>(lucidSignal: LucidSignal<T>): AngularSignal<T> {
if (signalMap.has(lucidSignal)) {
return signalMap.get(lucidSignal)!;
}
const valueChangedSignal = signal(lucidSignal.valueId); // construct an Angular signal
lucidSignal.effect(() => {
// Anytime our signal updates, mark the Angular signal as updated.
// This indirection is important because our `effect` is synchronous upon the signal being marked stale.
valueChangedSignal.set(lucidSignal.valueId);
});
const newSignal = computed(() => {
// Depend on the valueId, as a dependency only.
sinkValue(valueChangedSignal());
// but return the underlying lucidSignal value.
return lucidSignal();
});
signalMap.set(lucidSignal, newSignal);
return newSignal;
}
} Uses would look like this: <div>
{{(mySignal | toAngularSignal)()}}
</div> |
Beta Was this translation helpful? Give feedback.
-
The idea of equality can be troublesome and requires passing it every time. How about making assumptions instead:
And remain with the custom equal or a flag to override it |
Beta Was this translation helpful? Give feedback.
-
Discussion point 2a: given the trade-offs outlined here, would you prefer the Signal / WritableSignal naming pair or the ReadonlySignal / Signal one? I like Signal / WritableSignal, but since creating a new signal with The naming feels more consistent. |
Beta Was this translation helpful? Give feedback.
-
Still diving into the whole RFC and reading it as a whole. But one of the first ideas I got after seeing some examples in the "wild" (on Twitter), I figured that the Constructions like this A simple solution to this could be of course to have Signals as considered and adding a |
Beta Was this translation helpful? Give feedback.
-
Sub-RFC 2: Signal APIs
Changelog
April 10, 2023
asReadonly()
to theWritableSignal
APIeffect()
to schedule cleanup viaonCleanup
argument instead of returning a cleanup function (see fix(core): allow async functions in effects #49783)Introduction
This discussion covers the API surface and some of the implementation details for Angular’s signal library.
Signals
Fundamentals
A signal is a value with explicit change semantics. In Angular a signal is represented by a zero argument getter function returning the current signal value:
The getter function is marked with the
SIGNAL
symbol so the framework can recognize signals and apply internal optimizations.Signals are fundamentally read-only: we can ask for the current value and observe change notification.
The getter function is used to access the current value and record signal read in a reactive context - this is an essential operation that builds the reactive dependencies graph.
Signal reads outside of the reactive context are permitted. This means that non-reactive code (ex.: existing, 3rd party libraries) can always read the signal's value, without being aware of its reactive nature.
Writable signals
The Angular signals library will provide a default implementation of the writable signal that can be changed through the built-in modification methods (set, update, mutate):
An instance of a settable signal can be created using the signal creation function:
Usage example:
Signal and WritableSignal interfaces naming
In the current proposal the primary interface is named
Signal
. This represents a read-only value changing over time. We’ve chosen this name as it is short, discoverable and we are expecting it to be the most commonly imported and used interface.WritableSignal
are somewhat specialized and adding “writable” to the name indicates that additional operations are permitted on those types of signals.An alternative naming that we’ve considered is a pair of the
ReadonlySignal
(primary interface) andSignal
(writable flavor). This aligns nicely with the TypeScript naming schema (ex.ReadonlyArray
andArray
). We were hesitant to use this naming asReadonlySignal
is far less discoverable and API authors might reach out for theSignal
interface when their intention was to useReadonlySignal
, ex.:Discussion point 2a: given the trade-offs outlined here, would you prefer the
Signal
/WritableSignal
naming pair or theReadonlySignal
/Signal
one?Equality
It is possible to, optionally, specify an equality comparator function. If the equality function determines that 2 values are equal, and if not equal, writable signal implementation will:
The default equality function compares primitive values (numbers, strings, etc) using
===
semantics but treats objects and arrays as “always unequal”. This allows signals to hold non-primitive values (objects, arrays) and still propagate change notification, example:Other implementations of the signal concept are possible. Both Angular or 3rd party libraries can create customized versions - as long as the underlying contract is maintained.
.set is the fundamental operation, .update is a convenience method
While the API surface has 3 different methods (set, update, mutate) of changing signal’s value, the
.set(newValue)
is the only fundamental operation that we need in the library. The other 2 methods are just syntactic sugar, convenience methods that could be expressed as.set
.Example of
.update
expressed with.set
:While everything could be expressed using
.set
only, the.update
is often more convenient in certain use cases and hence were introduced in the public API surface.Discussion point 2b: is the convenience of the
.update
worth introducing, given the larger public API surface?.mutate is for changing values in-place
The
.mutate
method can be used to change a signal's value by mutating it. It is only useful for signals that hold non-primitive JavaScript values: arrays or objects. Example:The
.mutate
method will always send change notifications, bypassing the custom equality checks on the signal level.The combination of the
.mutate
method and the default equality function makes it possible to work with both mutable and immutable data in signals. We specifically didn’t want to “pick sides” in the mutable / immutable data discussion and designed the signal library (and other Angular APIs) so it works with both.Separation of read/write
In our signal library, we've made a design choice that the main reactive primitive (
Signal<T>
) is read-only. This means that it's possible to propagate reactive values to consumers without giving those consumers the ability to modify the value themselves.The separation of read/write capabilities will encourage good architectural patterns for data flow in signal-based applications. This is because mutation of state must be centralized and happen through the owner of that state (the component or service which has the
WritableSignal
) instead of happening anywhere within the application.Discussion point 2c: in some systems (e.g. Vue) reactive state is inherently mutable throughout the application. In other frameworks (e.g. SolidJS) this separation is enforced even more strongly. What do you think about our choice to separate readers and writers, and the architectural benefits or drawbacks of this approach?
Getter functions
In the Angular chosen implementation, a signal is represented by a getter function. Here are some of the advantages of using this API: :
Drawbacks of getter functions
Getter functions do have some downsides, covered below.
Function calls in templates
Angular developers have learned over the years to be wary of calling functions from templates. This advice arose because of the way change detection runs frequently for components, and the potential for functions to easily hide computationally expensive logic.
These concerns don't apply to signal getter functions, which are efficient accessors that do minimal computational work. Calling signal getters repeatedly and frequently is not an issue.
However, using function calls for signal reads might initially confuse developers who are used to avoiding function calls in templates.
Interaction with type narrowing
TypeScript can narrow the type of expressions within conditionals. The following code will type-check even if
user.name
is nullable, because TypeScript knows that within theif
body it can't benull
:However, TypeScript doesn't narrow function call return types, because it can't know that the function will return the same value every time it's called (like signal functions do). So the above example does not work with signals:
For this simple example, it's straightforward to extract
user.name()
to a constant outside of theif
:But this doesn't work in templates, as there is no way to declare an intermediate variable. There are some workarounds (we could create such variables automatically, for example).
Alternative syntaxes
We did consider different approaches and discarded them for the reasons listed below.
.value
.value
is a potentially viable API but wasn't chosen for the following reasons:user.value.name.value.first
vsuser().name().first
However, there are some advantages as well. As
.value
is a plain property access, it does not suffer from the same type narrowing limitations that getter functions do.Discussion point 2d: do the potential advantages of
.value
outweigh the disadvantages? Would you prefer that API?Decorators
Decorators are great at providing metadata and / or syntactic sugar and several people suggested usage of decorators. We've explored this options and discarded it for the following reasons:
Getter / setter tuple
This approach has a desired property of segregating read and write operations. Unfortunately, we can't use destructuring assignment when defining properties in JavaScript classes:
which made this API a non-starter.
Proxy
The initial steps of our reactivity story are focused on providing basic building blocks, the smallest primitives that we (and the Angular community) can build upon. Signals are such a building block that model reactivity for both primitive JavaScript values and complex objects. We can't proxy access to primitive values so we needed some other mechanism that could work for both primitive JavaScript values and objects / arrays.
Having said this, we do see potential usage of proxies in store-like constructs that encapsulate "bigger" JavaScript objects and / or collections. We might explore
Proxy
usage there and expect that community-driven,Proxy
-based state management solutions will be available in the future.Compile-time reactivity
Some UI frameworks take a compiler-based approach to reactivity: most notably Marko and Svelte. We did look into those methods and see many benefits, but at the end of the day we've decided to continue with a runtime-based solution.
Svelte-based reactivity results in an excellent developer experience, as the framework comes with built-in reactive language constructs. This greatly reduces "syntactical noise" and makes components code easier to write and read. Unfortunately this approach works only in components - as soon as we want to move reactive code outside of component boundaries (ex. to share it between components) we need to change the reactive paradigm and syntax by moving to Svelte stores. In Angular we wanted to work with the same reactive primitive across the entire application code base. Signals are usable in components, services and anywhere in the application, really.
Marko makes the reactive primitive available across the application but at the cost of "global analysis" in a dedicated compiler. In the past Angular was leaning heavily towards the "full knowledge" / "global analysis" compiler pass but it proved to be relatively slow and made Angular's compilation pipeline hard to integrate with the other tools in the JavaScript ecosystem. We want to shift Angular's to local, faster compilation. Global analysis of a reactive graph would go against this goal.
Computed signals
Computed signals create derived values, based on one or more dependency signal values. The derived value is updated in response to changes in the dependency signal values. Computed values are not updated if there was no update to the dependent signals.
Computed signals may be based on the values of other computed signals, allowing for multiple layers of transitive dynamic computation.
Example:
The signature of the computed is:
The computation function is expected to be side-effect free: it should only access values of the dependent signals (and / or other values being part of the computation) and avoid any mutation operations. In particular, the computation function should not write to other signals (the library's implementation will detect attempts of writing to signals from
computed
and raise an error).Similarly to the writable signals, computed signals can (optionally) specify the equality function. When provided, the equality function can stop recomputation of the deeper dependency chain if two values are determined to be equal. Example (with the default equality):
The algorithm chosen to implement the computed functionality makes strong guarantees about the timing and correctness of computations:
Branching in Computations
Computed signals keep track of which signals were read in their computations, in order to know when recomputation is necessary. This dependency set is dynamic, and self-adjusts with each computation. So in the conditional computation:
The
greeting
will always be recomputed if theshowName
signal changes, but ifshowName
is false, thename
signal is not a dependency of thegreeting
and will not cause it to recompute.Effects
An effect is a side-effectful operation which reads the value of zero or more signals, and is automatically scheduled to be re-run whenever any of those signals changes.
The basic API for an effect has the following signature:
Usage example:
Effects have a variety of use cases, including:
Effect functions can, optionally, register a cleanup function. If registered, cleanup functions will be executed before the next effect run. The cleanup function makes it possible to "cancel" any work that the previous effect run might have started. Example:
Scheduling and timing of effects
Effects in Angular Signals must always be executed after the operation of changing a signal has completed.
Given the variety of effect use-cases, there is a wide spectrum of possible execution timings. This is why the actual effect execution timing is not guaranteed and Angular might choose different strategies. Application developers should not depend on any observed execution timing. The only thing that can be guaranteed is that:
Stopping effects
An effect will be scheduled to run every time one of its dependencies change. In this sense an effect is “always alive” and ready to respond to the changes in a reactive graph. Such “infinite” lifespan is obviously undesired as effects should be shut down when an application stops (or some other life-scope ends).
By default Angular effects lifespan is linked to the underlying
DestroyRef
in the framework. In other words: effects will try to inject the currentDestroyRef
instance and add register its stop function in there.For situations where more control over lifespan scope is required, one can optionally pass the
manualCleanup
option to theeffect
creation:If this option is set, the effect won't be automatically destroyed even if the component/directive which created it is destroyed.
Effects can be explicitly stopped / destroyed by using the EffectRef instance returned from the effect creation function:
Effects writing to signals
We generally consider that writing to signals from effects can lead to unexpected behavior (infinite loops) and hard to follow data flow. As such any attempt of writing to a signal from an effect will be reported as an error and blocked.
This default behavior can be overridden by passing the
allowSignalWrites
options to the effect creation function, ex.:Please note that
computed
is often a more declarative, straightforward and predictable solution to synchronizing data:Frequently asked questions
Can I create signals outside of components / stores / services?
Yes! You can create and read signals in components, services, regular functions, top-level JS module code - anywhere you might need a reactive primitive.
We see this as a huge benefit of signals - reactivity is not exclusively contained within components. Signals empower you to model data flow without being constrained by the visual hierarchy of a page
Any guidelines when it comes to granularity of signals?
This is a common question! Given a non-trivial object, it is not obvious how many signals should be created: one signal for the entire object? Or maybe one signal for each individual property?
Currently we can't provide hard-and-fast rules here but would suggest starting with more coarse-grained objects (one signal for the entire object) and split up if necessary. While it is tempting to go with many fine grained signals it is often not practical (creating all those signals can get verbose!) and - counterintuitively - not that performant (creating and maintaining signals in memory has associated cost).
Should I use mutable or immutable data in my signals?
Signals work great with both, we don't want to "pick sides" but rather let developers choose the approach that works best for their teams and use-cases.
Signals library
Why a new library instead of using an existing one?
Most of the existing implementations are tightly integrated with the underlying framework needs. From the Angular perspective we want to pick and choose semantics and an API surface that matches our needs. Some examples where we do have clear preferences:
Finally, having direct dependency on the 3rd party library comes with non-trivial constraints: one needs to be aligned on concepts, implementation details and release scheduling.
On the other hand, reactive signal libraries tend to be fairly small, both in terms of the conceptual / API surface and implementation (~500 LoC).
How is it different from MobX, SolidJS, Vue reactivity?
Angular signals belongs to the same family of approaches and share core characteristics, same philosophy and architecture:
Core implementation ideas are also the same:
Despite the large number of similarities, there are substantial differences between various implementations: both on the conceptual, API and algorithmic levels:
Will you publish the library as a separate npm package?
We did discuss the possibility of publishing an independent signal library but didn't do so initially for the following reasons:
We will definitely consider publishing a separate NPM package if there is value in it - please leave feedback in the RFC if you would like to see Angular signals library to be available as a separate NPM package.
Beta Was this translation helpful? Give feedback.
All reactions