Angular 20 has officially landed! 🎉 As expected, one of the most exciting updates is the continued evolution of Signal-based APIs, a huge step forward in Angular's reactive programming model.
In this post, we'll explore one such API — toSignal()
— and learn how it helps bridge the gap between Observables and Signals.
🔁 What is toSignal()
?
The toSignal()
function allows you to convert an Observable stream into a Signal, enabling integration with Angular’s fine-grained reactive system.
This makes it easier to work with API responses, state changes, and other reactive data sources in a way that's more performant and declarative.
toSignal<T>(source: Observable<T>, options?: {
initialValue?: unknown;
requireSync?: boolean;
manualCleanup?: boolean;
injector?: Injector;
equal?: ValueEqualityFn<T>;
}): Signal<T>
✅ Basic Example: Converting an HTTP Observable to a Signal
Here’s a quick demo of how toSignal()
can be used to fetch data from a JSON file and convert the response into a Signal.
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit, effect } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
@Component({
selector: 'app-angular-api',
templateUrl: './angular-api.html',
styleUrl: './angular-api.scss',
})
export class AngularApi implements OnInit {
private http = inject(HttpClient);
URL = './json/country.json';
countryList = toSignal(
this.http.get<any>(this.URL).pipe(map((res) => res))
);
constructor() {
effect(
(onCleanup) => {
console.log('country value :', this.countryList());
onCleanup(() => {
console.log('clean up callback triggered');
});
}
);
}
}
Here, we use toSignal()
to convert the HTTP observable into a reactive signal, which automatically updates when the observable emits.
🧼 Note:
TheonCleanup()
inside theeffect()
function gets triggered:
- When the injection context is destroyed (e.g., component, service gets destroyed)
- Or when the signal dependency changes and the effect re-runs.
⚠️ requireSync
Option
When requireSync: true
is used, Angular expects the observable to emit immediately upon subscription.
✅ Works with Synchronous Observables
countryList = toSignal(
of([{ name: { common: 'Aruba' }, region: 'Americas', flag: '🇦🇼' }]),
{ requireSync: true }
);
Since of()
emits synchronously, this works just fine.
Certainly! Here's how you can rewrite that line as a note for a Dev.to blog post in a clean and developer-friendly format:
📝 Note:
When usingrequireSync: true
, there's no need to provide aninitialValue
, because the observable is expected to emit synchronously.
As a result, the returned signal will not includeundefined
in its type.
Let me know if you'd like to bundle this with other requireSync
tips or use it inline with an example.
❌ Fails with Asynchronous Observables
countryList = toSignal(
of([{ name: { common: 'Aruba' } }]).pipe(delay(500)),
{ requireSync: true }
);
💥 Adding a delay turns the stream asynchronous, and Angular throws an error:
ERROR RuntimeError: NG0601: `toSignal()` called with `requireSync` but `Observable` did not emit synchronously.
🧠 Quick Tip:
timer()
,interval()
, andHTTP
calls are asynchronous,
whileof()
,BehaviorSubject
, andReplaySubject
are synchronous.
🛠️ initialValue
Option
You can provide a default value for your signal using the initialValue
option. This is useful when your observable takes time to emit.
timer$ = timer(1000);
timerLog = toSignal(timer$, {
initialValue: 0
});
📝 Note:
TheinitialValue
provided totoSignal
will be used until the observable emits its first value. This is especially useful when dealing with asynchronous streams that don’t emit immediately.📝 The signal will start with the value
0
and update whentimer$
emits.
🔁 Custom Equality with equal
The equal
function allows you to define custom logic to determine whether the signal should update.
timer$ = timer(1000);
timerLog = toSignal(timer$, {
initialValue: 0,
equal: (prev:number, curr:number) => {
console.log(prev, curr);
return curr === 5;
}
});
✅ If the function returns
true
, the signal won’t update.
❌ If it returnsfalse
, it will.
💉 Using injector
Outside the Injection Context
Trying to use toSignal()
outside of the constructor or runInInjectionContext()
will result in
ERROR RuntimeError: NG0203: toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`
But you can fix this by explicitly passing the injector
:
this.countryList = toSignal(
this.http.get<any>(this.URL).pipe(map((res) => res)),
{ injector: this._injector }
);
🧹 Controlling Cleanup with manualCleanup
By default, toSignal()
will automatically unsubscribe when the component is destroyed. But with manualCleanup: true
, you control the cleanup.
this.countryList = toSignal(
this.http.get<any>(this.URL).pipe(
takeUntil(this._destroy$),
map((res: any) => res)
),
{ manualCleanup: true }
);
📌 Final Thoughts
Angular 20's signal APIs—especially toSignal()
—are making state management and reactive data flow much easier and more powerful.
Whether you're working with async HTTP calls, state streams, or UI interactions, understanding how to leverage toSignal()
and its options can help you build faster, cleaner, and more maintainable apps.
Let me know if you'd like a follow-up post on computed
, effect
, or other new Angular 20 features!
✍️ Author: Vetriselvan
👨💻 Frontend Developer | Code Lover | Exploring Angular’s future
[hidden by post author]