DEV Community

Cover image for 10 Tips for Scaling Signals
Mike Pearson
Mike Pearson

Posted on • Edited on

10 Tips for Scaling Signals

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

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

I suggest exporting this from an Nx lib, or giving the file a TypeScript path alias.

StackBlitz

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

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())),
Enter fullscreen mode Exit fullscreen mode

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:

  1. Method
  2. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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

This has many of the same advantages as Redux/NgRx:

  1. 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
  2. Grouping relevant logic makes debugging easier
  3. 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

Or we could export a subject:

export const resetAll = new Subject<void>();
Enter fullscreen mode Exit fullscreen mode

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$;
}
Enter fullscreen mode Exit fullscreen mode
<button (click)="resetAll$.next()">Reset All</button>
Enter fullscreen mode Exit fullscreen mode

Here's the difference in data flow between these approaches:

the difference in data flow between method vs subject auto-signals

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

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

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

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

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

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

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

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

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

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']);
...
Enter fullscreen mode Exit fullscreen mode

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

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)

Collapse
 
tsharp profile image
timonkrebs • Edited

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.

Collapse
 
mfp22 profile image
Mike Pearson

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.

Collapse
 
tsharp profile image
timonkrebs

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

Thread Thread
 
mfp22 profile image
Mike Pearson

Interesting. Well, good luck when you get the time.

Collapse
 
dominikpieper profile image
Dominik Pieper

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?

Collapse
 
mfp22 profile image
Mike Pearson

Oh, thanks for the reminder! I need to update this.