[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.
-
2a: Yes, Seeking clarification:
const greeting = computed(() => showName() ? `Hello, ${name()}!` : 'Hello!');
If
Is there some kind of batch update, or a debounce? If the effect is only run once, how can we be sure it is being run with the final values of each of the dependent signals?
How would you envision a scenario where an effect is run to make a http request to fetch the latest data when a signal has changed, then write the latest data to a new signal? const httpClient = inject(HttpClient);
const searchQuery = signal('');
const searchResults = signal({});
effect(() => {
httpClient.get(`/api/search/${searchQuery()}`).subscribe(data => searchResults.set(data.results));
}, {allowSignalWrites: true}); I noticed also in a separate comment that there was an idea about adding an AbortSignal to effects. I think this would be worthwhile. It's almost an implementation of All in all, great work, I'm v.excited to see this become available! 🎉 |
Beta Was this translation helpful? Give feedback.
-
Have the risks of allowing and even encouraging to mutate values held in a signal using I'm particularly concerned with in-place mutations in the context of RxJS interoperability. Observables are inherently "sequences of values over time", with the implicit assumption those values are immutable. There are many RxJS patterns and operators that rely on caching and exposing historical values (e.g. I understand how the |
Beta Was this translation helpful? Give feedback.
-
There are a few other comments on this RFC page about the topics I'm interested in, so perhaps opening some specific issues to collect those common themes would be useful. EffectsI think this RFC is least specific about effects, and as a result it's hard to see which use cases effects would support without more specification and precisely how and when effects are scheduled to run. The RFC mentions "there is a wide spectrum of possible execution timings" but that's a bit scary, especially when effects will want to trigger other actions that may then re-trigger signal modifications (whether or not that signal modification happens wrapped in a effect or not). I think if effects always run synchronously after signals, that makes them easier to reason about, but you might over-notify effects for instance if a bunch of signals change within the same task. In any case users who want specific debouncing, scheduling, or execution guarantees will be required to essentially implement that themselves, and cannot make use of BatchingI notice there's no batching API for signals - or did I miss that? Preact, e.g. has |
Beta Was this translation helpful? Give feedback.
-
We have a use case that seems like nested signals - will it be supported? We are using a store injectable that can be created multiple times. This store can have a component which accepts the store as an input. With signals this will require nested signals and I wonder if it will work. class Store {
counter = signal(0);
}
const STORE_ONE = new InjectionToken<Store>('One');
const storeOneProvider: Provider = {
provide: STORE_ONE,
useClass: Store,
};
const STORE_TWO = new InjectionToken<Store>('Two');
const storeTwoProvider: Provider = {
provide: STORE_TWO,
useClass: Store,
};
@Component({
selector: 'app-root',
template: `
<app-test [store]="storeOne"></app-test>
<app-test [store]="storeTwo"></app-test>
`,
providers: [storeOneProvider, storeTwoProvider],
})
export class AppComponent {
constructor(
@Inject(STORE_ONE) public storeOne: Store,
@Inject(STORE_TWO) public storeTwo: Store
) {}
}
@Component({
signals: true,
selector: 'app-test',
template: `{{ store().counter() }}`,
})
export class TestComponent {
store = input<Store>(); // Required
} Will |
Beta Was this translation helpful? Give feedback.
-
What if |
Beta Was this translation helpful? Give feedback.
-
I do like the idea of having them be separate, but one thing I wasn't clear on is what you anticipate the code might look like for a service that's sharing a readonly version of a signal. Like, let's say there's a private _user = signal(someConstructedUser);
public user = (): User => _user(); But that wouldn't really require a separate read only type. So, were you thinking there would be a cleaner way to expose the read only state of a |
Beta Was this translation helpful? Give feedback.
-
Thanks for all your great work on this and the RFC. 2a - I prefer the |
Beta Was this translation helpful? Give feedback.
-
Hello! Thanks for the great write up.
Yes
I like the current approach.
I prefer the function call as a getter. Specially if we can get type narrowing to work in templates. To me type narrowing is the one issue I do not like with the function call. One question that I had while looking at this; was the concept of |
Beta Was this translation helpful? Give feedback.
-
What if we need a copy of signal value? is there any clone feature for |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
Hello, I need it for learning/explaining Also, from some tests I saw that the effect timing for the moment is tied to zone.js. What do I mean by this: Shouldn't this be added to the RFC details? Also, does it mean we shouldn't depend on effect for api calls / asynchronous stuff because timing may change in the future, and maybe break logic of apps? const count = signal(0);
// the effect won't run, if you move the setInterval inside the constructor it works
setInterval(() => {
count.update((x) => x + 1);
}, 1000);
@Component({ selector: 'my-app', template: '', standalone: true })
export class AppComponent {
constructor(ngZone: NgZone) {
effect(() => {
console.log('Count changed: ', count());
});
// this also won't work
// ngZone.runOutsideAngular(() => {
// setInterval(() => {
// count.update((x) => x + 1);
// }, 1000);
// });
// this works
// setInterval(() => {
// count.update((x) => x + 1);
// }, 1000);
}
} Here's the stackblitz. I love these RFCs ❤️ |
Beta Was this translation helpful? Give feedback.
-
Hi there, We've developed our own reactive framework to solve the diamond problem, glitches etc (we used transactions) and are currently looking at the possibility of using signals as the base reactive type. It looks pretty promising at this stage but there's one use case we use often that I'm not sure how to go about it with signals. We often merge data from different async sources and need a way to postpone computation until some external condition is met. Typically this is a boolean model provided by our datasources but in some cases it has needed to be derived from the data itself. He's my attempt to describe what we're doing in 'signal' speak. const devices: Signal<Device[]> = deviceDataSource.model; // loaded asynchronously and can reload at any time
const deviceLocationsByDeviceId: Signal<ImmutableMap<string, Location>> = locationDataSource.model; // same as the above
// we want to delay our computed model calculations until this signal is true.
// usually this is just compute(() => deviceDatasource.loaded() && locationDatasource.loaded())
// but there are situlations where we've needed to base this on the contents of our data..
//. e.g. loaded = compute(() => someTestThatCheckTheContents(devices(), deviceLocations()));
const loaded: Signal<boolean> = compute(() => deviceDatasource.loaded() && locationDatasource.loaded());
// Now merge our models for the UI to use.
const devicesWithLocations = compute(() => {
return devices().map(d => {
return Mixin.create(d, {
location: deviceLocationsByDeviceId().get(d.id);
});
});
}, {
// don't compute unless/until loaded() === true.
postponeUntil: loaded
}) This is a feature we use a lot. Is there a signal way of doing this? Thanks! |
Beta Was this translation helpful? Give feedback.
-
Hi, just want to discuss this idea |
Beta Was this translation helpful? Give feedback.
-
I feel like the name for 'effect' is wrong. Current implementation can be better described by 'watch'. |
Beta Was this translation helpful? Give feedback.
-
Not-an-expert talking here, so this might be a bad idea (Please let me know why if it is!): Since I'm already used to working with undefined values (many of my observables, for example), it would be nice if the default value of a signal with no initial value were user = signal() // No initial value here. This currently throws an error in 16.0.0-rc.1 In the Sub-RFC 3 it gives an example of a signal input which has an undefined initial value so it doesn't seem like it would be a problem. This comment also mentions undefined initial values but doesn't seem conclusive to me. Finally, the toSignal() api will return undefined if the observable doesn't have an initial value, without needing to explicitly specify |
Beta Was this translation helpful? Give feedback.
-
I have read in the RFC that However effects seem to run asynchronously, if I understand correctly. Is there a way to run a side-effect synchronously when a Signal changes with |
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