Harnessing the Power of Signals to Drive Observables in Angular
In certain scenarios, we need to activate an observable in response to changes in a signal’s value. Consider a situation where you have a ProjectComponent
that receives an ID as an input. Your goal is to retrieve the corresponding project each time the ID changes:
@Component({
selector: 'app-project',
standalone: true,
template: ``,
})
export class ProjectComponent {
#service = inject(ProjectsService);
id = input.required<string>();
}
The ID value can be obtained from the browser URL as well, by utilizing the Router with the withComponentInputBinding()
feature.
It’s worth noting that this example utilizes the new, yet-to-be-released signal input feature. However, the same outcome can be achieved with the current Input
decorator and a setter method:
@Component({})
export class ProjectComponent {
#service = inject(ProjectsService);
#id = signal<string | undefined>(undefined);
@Input({ required: true }) set id(value: string) {
this.#id.set(value);
}
}
To execute side effects in response to changes in a signal’s value, we use the effect
function:
@Component({
selector: 'app-project',
standalone: true,
template: `{{ project() | json }}`,
})
export class ProjectComponent {
#service = inject(ProjectsService);
id = input.required<string>();
project = signal<Project | undefined>(undefined);
constructor() {
effect(() => {
this.#service.getProject(this.id()).subscribe(project => {
this.project.set(project);
})
})
}
}
Each time the ID signal changes, we fetch the relevant project and update the project signal. The effect
function also offers an onCleanup
method, useful for unsubscribing from any previous subscriptions:
@Component({
selector: 'app-project',
standalone: true,
template: `{{ project() | json }}`,
})
export class ProjectComponent {
#service = inject(ProjectsService);
id = input.required<string>();
project = signal<Project | undefined>(undefined);
constructor() {
effect((onCleanup) => {
const sub = this.#service.getProject(this.id()).subscribe(project => {
this.project.set(project);
})
onCleanup(() => sub.unsubscribe())
})
}
}
Before invoking the provided effect
function, Angular will first execute our cleanup
function to ensure no lingering subscriptions remain.
However, the code in its current form lacks declarativeness and simplicity. To address this, let’s introduce a reusable function fromEffect
. This function accepts an array of signal dependencies and a function that returns an observable. We subscribe to this observable whenever any dependency changes:
interface Options {
injector?: Injector;
}
function fromEffect<
T,
const Deps extends Signal<any>[],
Values extends {
[K in keyof Deps]: Deps[K] extends Signal<infer T> ? T : never;
}
>(deps: Deps, source: (...values: Values) => Observable<T>, options?: Options) {
!options?.injector && assertInInjectionContext(fromEffect);
const injector = options?.injector ?? inject(Injector);
const sig = signal<T | undefined>(undefined);
effect(
(onCleanup) => {
const values = deps.map((dep) => dep()) as Values;
const sub = source(...values).subscribe((value) => {
sig.set(value);
});
onCleanup(() => sub.unsubscribe());
},
{ injector, allowSignalWrites: true }
);
return sig.asReadonly();
}
The fromEffect
function verifies the injection context and takes an optional injector for scenarios outside this context. It then creates a signal and an effect, streamlining the process detailed in our initial example. It also sets the allowSignalWrites
option to true
in case our observable emits synchronously. Now, we can refactor the first example as follows:
@Component({
selector: 'app-project',
standalone: true,
template: `{{ project() | json }}`,
})
export class ProjectComponent {
#service = inject(ProjectsService);
id = input.required<string>();
project = fromEffect([this.id], id => this.#service.getProject(id))
}
We can enhance this further by allowing to provide an initial value.
This approach simplifies the reactive component state management in Angular applications, making the code more declarative and easier to maintain.
Follow me on Medium or Twitter to read more about Angular and JS!