In this second part of the series, we're going to look at how to migrate one of the most common patterns in Angular apps: a service that stores its state in a BehaviorSubject.
While it's not mandatory to migrate this pattern, as BehaviorSubjects work perfectly well, but switching to signals can be a great way to simplify your codebase, remove observable boilerplate (like async pipes and subscribe blocks), and make your state management more declarative.
Before: Sharing data in a service with RxJs
Imagine you have to get products from your API, display them in a product-list component, and have them available through other components. That means we should have some sort of shared product state. That could be accomplished with the simple "Subject in a service pattern", as I will illustrate below:
Product service
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly http = inject(HttpClient);
private readonly products = new BehaviorSubject<Product[]>([]); // Behavior subject we can .next()
public readonly products$ = this.products.asObservable(); // public exposed value, readonly
public loadProducts(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products').pipe(
tap((products) => {
this.products.next(products); //
})
);
}
}
Then, in the component, we could fetch the data, and use the observable data stream exposed by the service. To get its value, we can use the AsyncPipe that subscribes to the stream.
Another strategy could be to perform a manual subscription with .subscribe() method. I tend to avoid this strategy as much as possible, to make my code more declarative, and avoid potential memory leaks if one forgets to unsubscribe, but that's another topic.
Product list component
@Component({
selector: 'app-product-list',
template: `
@for(product of products$ | async) {
<div>
{{ product.name }}
</div>
}
`,
imports: [AsyncPipe] // Must import async pipe for subscription in template
})
export class ProductListComponent {
private readonly productService = inject(ProductService);
public readonly products$ = this.productService.products$;
ngOnInit() {
this.productService.loadProducts(); // fetch products on init
}
}
Now: Sharing data with signals
With signals, we can keep the exact same idea: store the product list once in the service, and let several components use it, but with less boilerplate.
No more BehaviorSubject, no more asObservable(), and we don't need the AsyncPipe to unwrap the value.
Signals give us a simple, direct way to read and update the state.
Let's rewrite the ProductService using a signal instead of a BehaviorSubject:
Product service
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly http = inject(HttpClient);
private readonly _products = signal<Product[]>([]); // private writable signal
public readonly products = this._products.asReadonly(); // Expose readonly signal to the rest of the application
public loadProducts(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products').pipe(
tap((products) => {
this.products.set(products);
})
);
}
}
In the component, consuming the data becomes easier. We don't need the AsyncPipe, because a signal can be read like a simple getter:
Product list component
@Component({
selector: 'app-product-list',
template: `
@for(product of products()) {
<div>
{{ product.name }}
</div>
}
`,
imports: [] // no need for AsyncPipe
})
export class ProductListComponent {
private readonly productService = inject(ProductService);
public readonly products = this.productService.products; // use the signal directly
ngOnInit() {
this.productService.loadProducts();
}
}
The general idea is the same as before: the service fetches and stores the data, and any component can read it. But signals remove several layers of Observable-specific API, which makes the code a bit easier to follow.
Conclusion
Migrating a shared state service from a BehaviorSubject to a signal isn't mandatory, but it's an easy win if you want to reduce boilerplate and make your code more readable.
You keep the same idea (one source of truth in the service) but with fewer concepts to understand: no more asObservable(), no more async pipe, and a more direct way to access the data in your components. It's a small change, but it often makes the flow of data easier to follow.
In the next part of this series, we'll look at a bigger question: when should you keep RxJS and Observables instead of switching to signals?
Because even if signals are great for state, RxJS still shines for many other scenarios, and knowing when to keep each tool is what makes your architecture really solid.