Bridging Signals and NgRx
To start, it's worth remembering that Angular v16 will introduce a fresh package called rxjs-interop, designed to offer functions that translate RxJS observables into signals—and the reverse. Given that NgRx is fundamentally constructed atop Observables, it doesn't strictly require built-in signal support. In practice, this means you could replace something like this:
@Component({
selector: 'app-data',
template: `
<p>{{ data$ | async }}</p>
`,
})
export class DataComponent {
store = inject(Store);
data$ = this.store.select(selectData);
}
with this:
@Component({
selector: 'app-data',
template: `
<p>{{ data() }}</p>
`,
})
export class DataComponent {
store = inject(Store);
data = toSignal(this.store.select(selectData));
}
And that's essentially all there is to it.
That said, two lingering questions arise:
- The syntax feels somewhat bulky; repeatedly reaching for
toSignalgrows tiresome and begs the question—if we're going this route, why rely onObservables in the first place? - What if the goal is to construct an entire store from signals?
Let's explore how the NgRx team intends to tackle these points, moving in chronological order and beginning with the second one.
Introducing NgRx SignalStore
Just two days after the Signals RFC surfaced on March 6, the NgRx core team released their own proposal—the NgRx SignalStore. For a deep dive, check that discussion; here's a condensed overview.
- You'll define a store using a dedicated function,
createSignalStore, augmenting it with properties and features through helpers such aswithState,withEffects, and similar utilities. A minimal example looks like this:
export const counterStore = createSignalStore(
withState<CounterState>({ count: 0 }),
withComputed((state) => ({
doubleCount: state.count * 2,
})),
);
Injecting this store into a component grants access to the count and doubleCount signals, which you can then use directly in your template:
@Component({
selector: 'app-counter',
template: `
<p>{{ counterStore.count() }}</p>
<p>{{ counterStore.doubleCount() }}</p>
`,
})
export class CounterComponent {
counterStore = inject(CounterStore);
}
State mutations can be performed via the update function:
counterStore.update((state) => ({ count: state.count + 1 }));
Alternatively, you have the option to define custom updater methods:
export const counterStore = createSignalStore(
withState<CounterState>({ count: 0 }),
withComputed((state) => ({
doubleCount: state.count * 2,
})),
withUpdaters(() => ({
increment: (state) => ({ count: state.count + 1 }),
})),
);
These custom methods are then callable from components and elsewhere. The feature set extends well beyond this, so feel free to dig into the full RFC or pose questions in the discussion or comments section below.
Signals for Existing NgRx Stores
Now, back to the first approach. Suppose you have an established application built on the traditional RxJS-driven NgRx Store, but you'd like to seamlessly adopt signals rather than juggling Observables and the async pipe—without inundating your code with boilerplate. Is NgRx equipped to assist? As it turns out, yes.
On March 12, the NgRx team followed up with another proposal—the Integration with Angular Signals and NgRx packages—which clarifies their strategy. Give it a read for complete details, but here's the gist: the Store service will gain a method alongside select, called selectSignal. It operates exactly like select, with one key distinction—it returns a Signal instead of an Observable. The same method is slated to be added to ComponentStore as well, behaving identically.
Returning to our earlier example, with this addition you can simply write:
@Component({
selector: 'app-data',
template: `
<p>{{ data() }}</p>
`,
})
export class DataComponent {
store = inject(Store);
data = this.store.selectSignal(selectData);
}
And that takes care of it. Everything else about the store remains unchanged—no migrations, no headaches.
The broader implications
Let’s look at two key consequences worth keeping in mind.
1. Store values would be accessible from anywhere, synchronously
If you check the NgRx store implementation, you’ll notice that the Store injectable extends RxJS Observable — not BehaviorSubject. That distinction matters because the store does not expose the current state as a plain value. It only emits snapshots over time. As a result, code like this simply won’t work:
@Component({
selector: 'app-data',
template: `
<p>{{ data$ | async }}</p>
`,
})
export class DataComponent {
store = inject(Store);
someService = inject(SomeService);
data$ = this.store.select(selectData);
useData() {
// do something with this.data
this.someService.doSomething(this.data$);
}
}
That snippet fails because this.data$ is an Observable — an event-driven wrapper around a value — not the value itself. To use it synchronously, you would have to subscribe and then manually unsubscribe, which is far from ideal. An alternative would be:
@Component({
selector: 'app-data',
template: `
<p>{{ data$ | async }}</p>
<button *ngIf="data$ | async as data"
(click)="useData(data)">
Use data
</button>
`,
})
export class DataComponent {
store = inject(Store);
someService = inject(SomeService);
data$ = this.store.select(selectData);
useData(data: Data) {
// do something with data
this.someService.doSomething(data);
}
}
That approach works for straightforward cases, but it doesn’t scale well when logic gets more involved. With Signals, however, you can always retrieve the current value on demand:
@Component({
selector: 'app-data',
template: `
<p>{{ data() }}</p>
<button (click)="useData()">Use data</button>
`,
})
export class DataComponent {
store = inject(Store);
someService = inject(SomeService);
data = this.store.selectSignal(selectData);
useData(data: Data) {
// we can now read from the signal
this.someService.doSomething(data());
}
}
This shift could — and likely will — change how components consume store data at a fundamental level.
2. Established best practices may need a fresh look
NgRx ships with its own ESLint plugin, enforcing a set of fairly opinionated rules. One of them says you should avoid using RxJS operators to transform state and should rely on dedicated selectors instead. The following would be flagged as bad practice:
export class Component {
name$ = this.store
.select(selectLoggedInUser)
.pipe(map((user) => ({ name: user.name })));
}
Instead, the recommended pattern looks like this:
// in selectors.ts:
export selectLoggedInUserName = createSelector(
selectLoggedInUser,
(user) => user.name
)
// in component:
export class Component {
name$ = this.store.select(selectLoggedInUserName)
}
The linter successfully prevents this kind of manipulation when you’re working with RxJS-based store data. But with Signals, you could effectively bypass that rule by using the computed function:
export class Component {
user = this.store.selectSignal(selectLoggedInUser);
name = computed(() => ({ name: this.user().name }));
}
Whether that’s considered an anti-pattern is an open question and likely a topic of discussion. In my view, it’s still better to keep state transformations inside the store layer and reserve components for presentation concerns. Use computed mainly to combine store signals with component-local signals. That said, this is only my perspective — I’d genuinely like to hear what others think.
Closing thoughts
First, I want to thank the NgRx Core Team and everyone else contributing for consistently staying ahead of the curve and delivering high-quality features with speed and precision. I’m genuinely curious to see how this evolves, and I’m confident the community stands to gain a lot from it.
Signals are generating a lot of excitement, and we’re currently in a period rich with discussion, decisions, and experimentation. Expect to see more of that in the coming months, and I’m eager to follow along. Feel free to share your thoughts in the comments — whether about this article, the NgRx RFCs, or the Angular Signal RFCs. It’s a great time to discuss, understand, and grow together.
More updates are on the way — stay tuned!
