Skip to content

Async computed signal - Add the ability to easily create async signals with dependencies #54032

@rezoled

Description

@rezoled

Which @angular/* package(s) are relevant/related to the feature request?

core

Description

Currently, the computed operator for signal provides the ability to create a new signal value, which is dependent on other signals.

The limitation I want to solve is that currently, the computed operator only allows for synchronous computations with immediate resolution of the newly computed value.

My suggestion is to allow computed signals to accept an async value (promise or observable), which when combined with a flag would wait until an actual value returns from the async value before updating the signal.

Proposed solution

Use case -

@Component({
	template: `
	<input (change)="query.set($event.value)">
	`,
})
class TestComponent {
	query = signal('');
}

Consider the use case in the component above, where on every keystroke I want to fetch some data from a backend service.

Example - Promise:

@Component({
	template: `
	<input (change)="query.set($event.value)">
	`,
})
class TestComponent {
	query = signal('');
	
	service = inject(DataService);
	
	data:Signal<Data | undefined> = computed(
	       () => this.service.queryAsPromise({query: this.query()}), // returns a promise 
		{ async: true }
	)
}

Example - Observable:

@Component({
	template: `
	<input (change)="query.set($event.value)">
	`,
})
class TestComponent {
	query = signal('');
	
	service = inject(DataService);
	
	data:Signal<Data | undefined> = computed(
		() => this.service.queryAsObservable({query: this.query()}), // returns an observable
		{ async: true }
	)
}

Using the computed operator with an async callback, but without specifying the async flag would result in a signal that holds an async value (same behavior today so not a breaking change):

@Component({
	template: `
	<input (change)="query.set($event.value)">
	`,
})
class TestComponent {
	query = signal('');
	
	service = inject(DataService);
	
	data:Signal<Promise<Data>> = computed(
		() => this.service.queryAsPromise({query: this.query()}), 
	)
}

Alternatives considered

Currently this is possible only with these 2 options:
1 - Using promise and effect which writes to a new signal:

@Component({
	template: `
	<input (change)="query.set($event.value)">
	`,
})
class TestComponent {
	query = signal('');
	
	data = signal([]);

	constructor(service:DataService) {
		effect(async () => {
			const query = this.query()
			const result = await service.queryAsPromise({query});
			this.data.set(result);
		}, { allowSignalWrites: true })
	}
}

2 - Using observable and jumping back and forwards from signal and observables to merge all required data (not to mention the added complexity if you have several dependencies for your async request):

@Component({
	template: `
	<input (change)="query.set($event.value)">
	`,
})
class TestComponent {
	query = signal('');
	
	service = inject(DataService);
	
	data = toSignal(
		toObservable(this.query)
			.pipe(mergeMap(query => this.service.queryAsObservable({query})))
	);
}

Both solutions are very verbose, convoluted, and seems like bending the framework to do something it wasn't supposed to in terms of ergonomics (my opinion).
The usecase seems pretty trivial, and I come across it constantly in our projects, so much so that I created a new function just to wrap this logic (toSignalWithDeps).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimecore: reactivityWork related to fine-grained reactivity in the core framework

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions