Harnessing the Power of Signals to Drive Observables in Angular

Netanel Basal
Netanel Basal
Published in
3 min readJan 8, 2024

--

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!

--

--

A FrontEnd Tech Lead, blogger, and open source maintainer. The founder of ngneat, husband and father.