DEV Community

Cover image for Signals: what this means for NgRx
Armen Vardanyan for This is Angular

Posted on

Signals: what this means for NgRx

Original cover photo by Carlos Alberto Gómez Iñiguez on Unsplash.

On April 4th, 2023, the Angular team officially launched an RFC for the new reactive primitive, signals, that will arrive as a developer preview in Angular v16. This could mean a beginning of a new era of Angular development, less dependence on RxJS, more granular change detection, and maybe even zoneless applications.

But what this means for us, NgRx enthusiasts? Let's find out.

Note: we assume that the reader is already familiar with the concept of Signals. If not, I suggest reading the aforementioned RFC, or at least the Sub-RFC 2 (which talks about the signals themselves) and Sub-RFC 4 (which talks about the RxJS-signals interoperability).

How NgRx might work with signals

First of all, we need to keep in mind that Angular v16 is going to introduce a new package, rxjs-interop, which will contain functions that will allow to conversion of RxJS observables to signals and vice versa. As NgRx is built on top of Observable-s, NgRx itself doesn't really need an innate support of signals. Essentially, instead of this:

@Component({
  selector: 'app-data',
  template: `
    <p>{{ data$ | async }}</p>
  `,
})
export class DataComponent {
  store = inject(Store);  
  data$ = this.store.select(selectData);
}
Enter fullscreen mode Exit fullscreen mode

We can do this:

@Component({
  selector: 'app-data',
  template: `
    <p>{{ data() }}</p>
  `,
})
export class DataComponent {
  store = inject(Store);  
  data = toSignal(this.store.select(selectData));
}
Enter fullscreen mode Exit fullscreen mode

And that would be it.

However, there are two concerns

  1. The code looks a bit verbose, and it will be tedious to call toSignal all the time, raising a question - why are we even using Observable-s at all?
  2. What if I want to build an entire store on signals?

Let's see how the NgRx team proposes addressing these issues. We will go chronologically, so will start with the second point.

NgRx Signal Store

On March 6, just 2 days after the Signals RFC, the NgRx core team published their own Signals RFC, titled NgRx SignalStore. Give it a read if you want more details, but here we will cover it in a shorter way.

  1. You will be able to define a store via a special function, createSignalStore, and add properties and features to it with other helper functions, like withState, withEffects, etc. Here is a small snippet:
export const counterStore = createSignalStore(
    withState<CounterState>({ count: 0 }),
    withComputed((state) => ({
        doubleCount: state.count * 2,
    })),
);
Enter fullscreen mode Exit fullscreen mode

So if we inject this into our component, we would have access to the count and doubleCount signals, which we can use in our template:

@Component({
  selector: 'app-counter',
  template: `
    <p>{{ counterStore.count() }}</p>
    <p>{{ counterStore.doubleCount() }}</p>
  `,
})
export class CounterComponent {
  counterStore = inject(CounterStore);
}
Enter fullscreen mode Exit fullscreen mode

And we could update the state with the update function:

counterStore.update((state) => ({ count: state.count + 1 }));
Enter fullscreen mode Exit fullscreen mode

Or we could choose to add custom updater methods:

export const counterStore = createSignalStore(
    withState<CounterState>({ count: 0 }),
    withComputed((state) => ({
        doubleCount: state.count * 2,
    })),
    withUpdaters(() => ({
        increment: (state) => ({ count: state.count + 1 }),
    })),
);
Enter fullscreen mode Exit fullscreen mode

And use these updater methods in components and so on. It has way more functionality to it, so feel free to explore the RFC, or ask questions either there or here in the comments.

Existing NgRx stores

Let's now address the first bullet point. If we already have an existing application that uses the conventional, RxJS-based NgRx Store, but we really want to seamlessly use signals instead of Observable-s an async pipe without too much code, will NgRx help? Turns out they will

On March 12, the NgRx team published another RFC, that sheds light on how it will support signals for the conventional NgRx Store. The RFC is titled Integration with Angular Signals and NgRx packages, so again, give it a read if you want more details. But in two words, essentially the Store "service" will get another method apart from select, named selectSignal, which will work in exactly the same way as select, but will return a Signal instead of an Observable. The same method will also be added to ComponentStore, working in the same fashion.

So continuing the previous example, now we can just do the following:

@Component({
  selector: 'app-data',
  template: `
    <p>{{ data() }}</p>
  `,
})
export class DataComponent {
  store = inject(Store);  
  data = this.store.selectSignal(selectData);
}
Enter fullscreen mode Exit fullscreen mode

And that's it. The rest of the store will remain exactly as before, no hassle, no heavy migrations.

What all of this means?

I would like to address two points:

1. Store values would now be directly accessible everywhere.

If we visit the NgRx Store source code and take a look at the store itself, we might see that the Store injectable extends RxJS Observable, and not BehaviorSubject, meaning it does not really "hold on to", or at least, give access to the current state in the Store. So something like this would be impossible:

@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$);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now this will not work, because this.data$ is an Observable, so an event-driven wrapper of the value, and not the value itself. So if we want to use the value in a synchronous manner, we would have to subscribe to it, and then unsubscribe, which is not ideal. Or we can do something like this:

@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);
  }
}
Enter fullscreen mode Exit fullscreen mode

This will work, but not every scenario is addressed with this solution, and things can be more complex. However, with Signals, we can always access the current value:

@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()); 
  }
}
Enter fullscreen mode Exit fullscreen mode

And this is something that can (and probably will) mean some overhaul in how we use the store data in the component code itself.

2. Some best practices might need to be rethinked

NgRx has its own eslint plugin, which introduces some (rather strict) rules. One rule is that we should not use RxJS operators to map and modify the state, and instead rely on custom selectors. So the following is considered a bad practice:

export class Component {
  name$ = this.store
    .select(selectLoggedInUser)
    .pipe(map((user) => ({ name: user.name })));
}
Enter fullscreen mode Exit fullscreen mode

And instead, we should do something like this:

// in selectors.ts:
export selectLoggedInUserName = createSelector(
  selectLoggedInUser,
  (user) => user.name
)

// in component:
export class Component {
  name$ = this.store.select(selectLoggedInUserName)
}
Enter fullscreen mode Exit fullscreen mode

So the plugin will successfully prevent us from doing this with RxJS-based store data, but with signals, we can kinda work around it using the computed function:

export class Component {
  user = this.store.selectSignal(selectLoggedInUser);

  name = computed(() => ({ name: this.user().name }));
}
Enter fullscreen mode Exit fullscreen mode

Whether this is a bad practice or not still remains to be seen, and is up for debate. In my opinion, we should stick with the previous practice of keeping store calculations on the store side of things, and use the component only for presentation. But this is just my opinion, and I would love to hear yours in the comments computed only for combining store signals with local signals.

Conclusion

First of all, I would like to extend my appreciation to the NgRx Core Team and all other contributors for once again staying on top of the things and delivering top-notch features quickly and efficiently. I am really excited to see how this will play out, and I am sure that the community will benefit from this greatly.

Signals are super exciting, and we are in an era of lots of discussion, decision-making, and experimentation. I am sure that we will see more and more of this in the future, and I am really looking forward to it. In this spirit, you are welcome to comment on this article, NgRx RFC-s, and Angular Signal RFC-s to discuss, understand, and learn

Stay tuned for other developments!

Top comments (2)

Collapse
 
spock123 profile image
Lars Rye Jeppesen

Amazing, really really like where Signals are taking us.

Collapse
 
noataraxie profile image
Oz

thanks !