After converting ~1800 lines of code to signals, I have learned a few important lessons. Signals scale very well if you follow the tips in this article.
1. computed
can be inefficient by default
Edit: In the finalized version of signals, computed
no longer runs excessively! Skip to tip #2
How many times will this log?
export class Component {
e = signal({ f: { f: 'f' } });
f = computed(() => this.e().f);
constructor() {
effect(() => {
console.log(this.f());
});
setTimeout(() => {
this.child.c.update((e) => ({ ...e }));
}, 1000)
}
}
Well, the top-level object changes, but the value returned from the computed()
will be the exact same object reference { f: 'f' }
both times.
But this actually logs twice. This is different from createMemo
from SolidJS or createSelector
from NgRx, and I predict a lot of people will be surprised by this behavior when they encounter it.
The reason for this behavior is that the Angular team doesn't want to have downstream code miss updates in case the object or array is mutated without getting a new reference. There is a mutate
method on signals, after all.
But there's a way to opt-in to more efficient behavior: Pass in equal: (a, b) => a === b
.
This is the only behavior I want. So I wrapped computed
in my own function so I don't have to define that equal
function everywhere:
import { computed } from '@angular/core';
export function memo<T>(fn: () => T) {
return computed(fn, { equal: (a, b) => a === b });
}
I suggest exporting this from an Nx lib, or giving the file a TypeScript path alias.
2. Reuse logic, not state
The old implementation managed form state in NgRx, which is a perfectly reasonable thing to do, as I explained in this article.
However, unfortunately, most NgRx developers have never read my article on how to avoid shooting yourself in the foot by coupling state logic with actual state. But there are a lot of reusable forms patterns, so the previous implementation centralized all form state in a single reducer. This is inconvenient in a few ways, but no page contains multiple forms, so it's not a disaster.
Still, I wanted to colocate forms with relevant state. I thought of 2 ways of doing this: Reusable functions, or a base class. Generally I like avoiding inheritance, but I found defining a base class the simplest strategy in this case.
3. Signal update timing can break e2e tests
How many times do you think this will log?
export class CountComponent {
state = signal(5);
constructor() {
effect(() => console.log(this.state()));
this.state.set(6);
this.state.set(7);
}
}
Answer: Twice! 5
and 7
will be logged.
So if you have some code that results in a state change in an e2e test and expect the result to immediately be reflected, your test will break. In this app, the form state was not updated immediately, so I had to add a short wait before submitting the form. (One field was optional, so I couldn't simply wait for the submit button to become enabled.)
4. The auto-signal pattern is awesome
After rewriting 1856 lines of NgRx code to 1323 lines using the auto-signal pattern, I like it very much. It is very convenient to able to share signals in a way that doesn't block subscription information in RxJS streams. Both NgRx and toSignal
block RxJS subscriptions, so from the existing NgRx implementation I was able to remove all of the manual initialization, resetting and refetching code.
Look at all the code that became 100% unnecessary when RxJS was free to handle data dependencies:
// apps/conduit/src/app/app.component.ts
constructor(
private readonly store: Store,
...
) {}
ngOnInit() {
...
take(1),
...
.subscribe(() =>
this.store.dispatch(authActions.getUser()),
);
}
// libs/articles/data-access/src/lib/+state/article-list/article-list.effects.ts
export const loadArticle$ = createEffect(
(
actions$ = inject(Actions),
...
) => {
return actions$.pipe(
ofType(articleActions.loadArticle),
...
);
},
{ functional: true },
);
...
export const loadComments$ = createEffect(
(
actions$ = inject(Actions),
...
) => {
return actions$.pipe(
ofType(articleActions.loadComments),
...
);
},
{ functional: true },
);
// libs/articles/data-access/src/lib/+state/article-list/article-list.effects.ts
export const setListPage$ = createEffect(
(actions$ = inject(Actions)) => {
return actions$.pipe(
ofType(articleListActions.setListPage),
map(() => articleListActions.loadArticles()),
);
},
{ functional: true },
);
export const setListTag$ = createEffect(
(actions$ = inject(Actions)) => {
return actions$.pipe(
ofType(articleListActions.setListConfig),
map(() => articleListActions.loadArticles()),
);
},
{ functional: true },
);
export const loadArticles$ = createEffect(
(
actions$ = inject(Actions),
store = inject(Store),
...
) => {
return actions$.pipe(
ofType(articleListActions.loadArticles),
...
);
},
{ functional: true },
);
// libs/articles/feature-article/src/lib/article-guard.service.ts
this.store.dispatch(articleActions.loadArticle({ slug }));
...
tap(() =>
this.store.dispatch(
articleActions.loadComments({ slug }),
),
),
// libs/articles/feature-article/src/lib/article.component.ts
ngOnDestroy() {
this.store.dispatch(articleActions.initializeArticle());
}
// libs/articles/feature-article-edit/src/lib/article-edit.component.ts
ngOnDestroy() {
this.store.dispatch(formsActions.initializeForm());
}
// libs/articles/feature-article-edit/src/lib/article-edit.routes.ts
resolve: { articleEditResolver },
// libs/articles/feature-article-edit/src/lib/resolvers/article-edit-resolver.ts
export const articleEditResolver: ResolveFn<boolean> = (
route: ActivatedRouteSnapshot,
) => {
const slug = route.params['slug'];
const store = inject(Store);
if (slug) {
store.dispatch(articleActions.loadArticle({ slug }));
}
return of(true);
};
// libs/auth/data-access/src/lib/+state/auth.effects.ts
export const getUser$ = createEffect(
(
actions$ = inject(Actions),
...
) => {
return actions$.pipe(
ofType(authActions.getUser),
...
);
},
{ functional: true },
);
// libs/auth/feature-auth/src/lib/login/login.component.ts
ngOnDestroy() {
this.store.dispatch(formsActions.initializeForm());
}
// libs/auth/feature-auth/src/lib/register/register.component.ts
ngOnDestroy() {
this.store.dispatch(formsActions.initializeForm());
}
// libs/home/src/lib/home.component.ts
ngOnInit() {
...
this.getArticles();
...
}
// libs/home/src/lib/home.store.ts
ngrxOnStateInit() {
this.getTags();
}
// libs/profile/data-access/src/lib/+state/profile.effects.ts
export const getProfile$ = createEffect(
(
actions$ = inject(Actions),
...
) => {
return actions$.pipe(
ofType(profileActions.loadProfile),
groupBy((action) => action.id),
...
);
},
{ functional: true },
);
// libs/profile/data-access/src/lib/resolvers/profile-articles-resolver.ts
export const profileArticlesResolver: ResolveFn<boolean> = (
route: ActivatedRouteSnapshot,
) => {
const username = route.params['username'];
const store = inject(Store);
store.dispatch(
articleListActions.setListConfig({
config: {
...articleListInitialState.listConfig,
filters: {
...articleListInitialState.listConfig.filters,
author: username,
},
},
}),
);
return of(true);
};
// libs/profile/data-access/src/lib/resolvers/profile-favorites-resolver.ts
export const profileFavoritesResolver: ResolveFn<boolean> = (
route: ActivatedRouteSnapshot,
) => {
const username = route?.parent?.params['username'];
const store = inject(Store);
store.dispatch(
articleListActions.setListConfig({
config: {
...articleListInitialState.listConfig,
filters: {
...articleListInitialState.listConfig.filters,
favorited: username,
},
},
}),
);
return of(true);
};
// libs/profile/feature-profile/src/lib/profile.routes.ts
resolve: { profileArticlesResolver },
...
resolve: { profileFavoritesResolver },
// libs/profile/data-access/src/lib/resolvers/profile-resolver.ts
store.dispatch(profileActions.loadProfile({ id: username }));
// libs/settings/feature-settings/src/lib/settings.component.ts
@UntilDestroy()
...
ngOnInit() {
this.store.dispatch(authActions.getUser());
...
this.store
.select(selectUser)
.pipe(untilDestroyed(this))
.subscribe((user) =>
this.store.dispatch(
formsActions.setData({ data: user }),
),
);
}
// libs/settings/feature-settings/src/lib/settings.store.ts
map(() => this.store.dispatch(authActions.getUser())),
That is a lot of code.
It's a lot of potential bugs, too. For example: Why didn't settings.component.ts
have an ngOnDestroy
to reset the form state like the other form components did? Maybe that was a bug. Regardless, RxJS takes care of that stuff now.
Maintaining this logic is harder than learning the auto-signal pattern.
5. The reactive variation of auto-signals is cleaner
There are 2 ways to implement state changes with auto-signals:
- Method
- Subject
1. Method
export class CountStateService {
// State
state = signal(0);
// Non-UI events and effects
serverCount$ = inject(CountService).fetch();
// Auto-signal connection
connection$ = connectSource(this.state, this.serverCount$);
// State change methods
increment() {
this.state.update(n => n + 1);
}
}
This is most similar to what people are used to, but it encourages more of an imperative/non-unidirectional style of state management, so I prefer using subjects.
2. Subject
export class CountStateService {
// State
state = signal(0);
// All events/effects, including server and UI
serverCount$ = inject(CountService).fetch();
increment$ = new Subject<void>();
// New state; similar to Redux/NgRx reducer
newState$ = merge(
this.serverCount$,
this.increment$.pipe(map(() => this.state() + 1))
)
// Auto-signal connection
connection$ = connectSource(this.state, this.newState$);
}
This has many of the same advantages as Redux/NgRx:
- All logic that controls this state (other than the initial state) is centralized in the single declaration of
newState$
, which helps avoid bugs that arise from developers forgetting important context - Grouping relevant logic makes debugging easier
- Event sources are not burdened with downstream concerns
Imagine we had multiple counts, and a button to reset all of them. With methods, we would need another method to call the other increment
methods:
export class CountComponent {
count1StateService = injectAutoSignal(Count1StateService);
count2StateService = injectAutoSignal(Count2StateService);
resetAll() {
this.count1StateService.reset();
this.count2StateService.reset();
}
}
Or we could export a subject:
export const resetAll = new Subject<void>();
And import that into each state service, and react to its events inside the declaration newState$
in each service. This is just like an NgRx action. Using it in the component is easy:
export class CountComponent {
count1StateService = injectAutoSignal(Count1StateService);
count2StateService = injectAutoSignal(Count2StateService);
resetAll$ = resetAll$;
}
<button (click)="resetAll$.next()">Reset All</button>
Here's the difference in data flow between these approaches:
The subject version might look more complicated, but it's actually less code, and that code is more appropriately colocated.
Don't follow the dogmatic tradition of creating an event handler for every event. It is entirely unnecessary. If you need flexibility, it can be downstream from the subject. The subject perfectly represents that something happened; it is a perfect abstraction, which means it acts as a simple, self-contained block that can be easily built upon by any future code.
6. State changes and events should be separate
Originally I wrote the form state base class like this:
newData$ = new Subject<any>();
newDataChanges$ = this.newData$.pipe(
map((data) => ({ ...this.state(), data })),
);
dataUpdate$ = new Subject<any>();
dataUpdateChanges$ = this.dataUpdate$.pipe(
map((data) => ({
...this.state(),
data: { ...this.data(), ...data, touched: true },
})),
);
reset$ = new Subject<void>();
resetChanges$ = this.reset$.pipe(map(() => formsInitialState));
newState$ = merge(
this.newDataChanges$,
this.dataUpdateChanges$,
this.resetChanges$,
);
But I came to see the separation between events and state as more primary than the separation between individual event/state change pairs. The events themselves don't have an opinion on how state should react, and may even be used in other services to cause unrelated state to react, so it felt better to group them like this:
newData$ = new Subject<any>();
dataUpdate$ = new Subject<any>();
reset$ = new Subject<void>();
newState$ = merge(
this.newData$.pipe(
map((data) => ({ ...this.state(), data })),
),
this.dataUpdate$.pipe(
map((data) => ({
...this.state(),
data: { ...this.data(), ...data, touched: true },
})),
),
this.reset$.pipe(map(() => initialFormState)),
);
Once I made this change, I also noticed that newState$
resembled an NgRx reducer:
export const formReducer = createReducer(
initialFormState,
on(FormActions.newData, (data) => ({ ...this.state(), data })),
on(FormActions.dataUpdate, (data) => ({
...this.state(),
data: { ...this.data(), ...data, touched: true },
})),
on(FormActions.reset, () => initialFormState),
);
Basically, it felt more natural to me to group the state logic together. Also, when I have a hard time choosing between alternatives, I prefer the more succinct choice.
7. Base connection$
observables should be Observable<any>
If you want to share state change logic by extending a base auto-signal class, TypeScript will infer the type of connection$
and prevent you from changing it in a child class. We don't want this because it doesn't matter what kind of values connection$
emits. So, we should just type it as Observable<any>
:
this.connection$ = merge(
this.connection$,
connectSource(this.newState$),
) as Observable<any>;
8. Some HTTP utilities are useful
HTTP observables often emit different values for success vs error. States need to react differently to these events, so I found myself writing some code over-and-over again, and eventually made these reusable utilities instead:
export function filterSuccess<T>(
source: RequestObservable<T>,
): Observable<T> {
return source.pipe(
filter(
(result): result is T => !('errors' in (result as object)),
),
);
}
export function filterError<T>(
source: RequestObservable<T>,
): Observable<{ errors: {} }> {
return source.pipe(
filter(
(result): result is { errors: {} } =>
'errors' in (result as object),
),
);
}
Here's an example of how I used these:
articles$ = toObservable(this.listConfig).pipe(
debounceTime(100),
switchMap((config) => this.articlesService.query(config)),
share(),
);
articlesSuccess$ = this.articles$.pipe(filterSuccess, share());
articlesFailure$ = this.articles$.pipe(filterError, share());
...
newState$ = merge(
...
this.articlesSuccess$.pipe(
map((result): ArticleListState => {
// return new state
}),
),
this.articlesFailure$.pipe(
map((): ArticleListState => {
// return new state
}),
),
...
);
9. resetOnRefCountZero
can smooth transitions
Route guards present an awkward challenge for reactive apps. Imagine an auto-signal connected to an HTTP observable like this:
export class NameStateService {
state = signal('');
nameFromServer$ = inject(NameService).fetch();
connection$ = connectSource(this.state, this.nameFromServer$);
}
If we use nameFromServer$
directly in a route resolver, the signal won't ever get the data. We should use injectAutoSignal
like this:
export function resolveName() {
const state = injectAutoSignal(NameStateService).state;
return toObservable(state).pipe(
filter(s => s !== ''), // Wait for it to be defined
take(1), // Resolvers need observables to complete
);
}
But this observable completes before the route is loaded and any subscribers within the route have the opportunity to inject this auto-signal. This means there is a very short period of time where connection$
will have no subscribers. This triggers the finalize
inside connection$
, so state resets to ''
.
Thankfully, RxJS gives us a way to prevent connection$
from completing for a period of time until new subscribers arrive. We can define connection$
like this instead:
connection$ = connectSource(this.state, this.nameFromServer$).pipe(
share({ resetOnRefCountZero: () => timer(1000) }),
);
Our route will load much more quickly than 1000 ms
, but I thought it might be nice to wait that long in case someone accidentally clicks away from the route and comes back quickly. If they come back within a second, the old state will be preserved.
UX Note: Route guards should be avoided. It isn't a great user experience to click a button or link and wonder if it's broken or needs to be clicked again. I would much rather see some instant feedback as the route loads, even if that means looking at a spinner until the data arrives. I would even prefer getting kicked out of a route than watching a motionless app while it decides if I should be allowed to view a route. So I think route guards should be avoided in general. But I'm sure there are specific scenarios where they are necessary.
10. Accessing route data in services is a pain
Services don't belong to routes. Components do. Services belong to injectors.
What if you inject a service inside a component? You would need to inject it into the route's root component, and it wouldn't be clear why it was there. It wouldn't be used in that specific component.
In my case, it was a route resolver that first needed the service. Route resolvers are not part of the route itself, but they get route data passed in directly. This doesn't help if the service is trying to inject it.
Previously, when I've had a service providedIn 'root'
and wanted route data, I've just listened to router events and parsed the URL manually. It's usually not very hard, although I still hate it because it seems a little fragile. However, not even this option worked in this case, because when the resolver runs, the navigation events haven't been fired yet.
What I had to do was define a BehaviorSubject
in the service for holding route data and imperatively set it from the resolver. Every time the route reloads, the resolver runs, so this will always be up-to-date. But having to write imperative code bothers me. When looking at the BehaviorSubject
itself, there is no indication of where it gets its data from; you have to find all references to it and find where the resolver pushes data to it. But at least it worked.
Here's what it looked like:
export const profileResolver: ResolveFn<boolean> = (
route: ActivatedRouteSnapshot,
) => {
const profileStateService = inject(ProfileStateService);
profileStateService.routeParam$.next(route.params['username']);
...
Bonus Tip: Type object literal returns
This tip doesn't just apply to signals or auto-signals, but when defining newState$
with object literals I noticed some dangerously forgiving TypeScript behavior. It was allowing not just extra properties like usual (usually harmless), but incorrect types of expected properties. So, instead of relying on inference in this case, I suggest typing return values whenever defining newState$
for nontrivial state, like this:
export class SomeStateService {
...
newState$ = merge(
// Explicitly type return as State
this.increment$.pipe(map((): State => ({
...this.state(),
count: this.count() + 1,
}))),
)
}
This will give you much more useful TypeScript errors than just typing newState$
, like newState$: Observable<State> = ...
. Typing every single return type is repetitive, but the improved TS errors are worth it in my opinion.
Conclusion
I am having a lot of fun with RxJS + signals. As an Angular community, we are learning a lot together. And remember that signals are still in developer preview, so experiment with them and let the Angular team hear your feedback. I hope they change computed
to memoize everything, regardless of type. And personally, I wish it was named memo
, but maybe that's just me.
Thanks for reading!
Top comments (6)
Thanks for the tipps. What I missed was why and how to avoid effect. Is there any progress with signal operators? I am still working on it. But I got a little sidetracked.
What are your thoughts on the completion events you mentioned in the comments of your youtube vid? Would be very interested. Maybe you are interested in what I am working on so far. I have a private repo on github that I could share with you.
I've had a lot on my plate recently. I'm going to promote StateAdapt and RxJS a while longer before working on signal operators again. Maybe around next March I'll come back to it.
For completion events, maybe keep track of it explicitly somehow...
There's another approach to signal operators I tested with Qwik. It's a lot more hacky, but might be worth taking a look at. stackblitz.com/edit/qwik-starter-o...
I also heard of an approach to resumability Ryan Carniato has that doesn't require serialization... It doesn't lazy load code, but it defers execution and apparently that makes stuff way more performant on startup. It was in a livestream about 2 months ago. Angular is more likely to adopt that version of resumability, at least at first.
Anyway, serialization isn't the only reason to work on signal operators, but it's a big one for me.
Maybe someone should just release a simple library for signal operators, maybe just start with 5, and have the expectation that as more operators are added, the API might change over time. I have a hard time doing incremental things like that. I'm too much of a perfectionist. But that might be the most valuable thing at the moment.
I watched all the livestreams of Ryan Carniato. If I am not mistaken you mean the resumability aproach that would do ssr and only serialize the reactive graph. It would prevent the double data problem. But as I understand this it is actually quite close if not exactly the same thing that I would like to do when I say serialize the reactive graph.
At the moment I am working on something similar but the focus is quite different. I realised that the reactive graph with the dynamic-lazy-memoization actually is very useful in other cases than reactivity. For some reasons I wanted to provide a signal implementation in c#. Since c# is multithreaded this had to be thread save. That is how I realised that thread save Signals without the reactivity could be the basis of a really nice new concurrency model (maybe even a replacement for async await or even the actor model). I am quite happy with how it turned out. And it is really easy to make it reactive, so I also built the reactivity part for that. I have a fully working thread save signal implementation for c#.
I also have already some signal operator like concepts implemented. My goal is to build a js lib for signal operator based on the learnings I earn from the c# thing. But I do not know when I will get to that and I am also not sure if it is worth to do it for angular signals since they are so closely related with angular. I probably will try to get it to work with solid first.
Thank you for your stackblizes
Interesting. Well, good luck when you get the time.
Does the first still count? Mutate is removed from the final Signals, is the case about the computed performance still open or is that fixed with that?
Oh, thanks for the reminder! I need to update this.