DEV Community

Cover image for NgRx SignalStore Hacks: Beautiful DX with Custom Features
Romain Geffrault
Romain Geffrault

Posted on

NgRx SignalStore Hacks: Beautiful DX with Custom Features

I spent some time looking for a better way to expose signalStoreFeatures that require info from the existing store. And I’ve found a great solution for you!

Before/after applying custom signalStore feature pattern

Here’s the example provided by NgRx to add a feature to the signalStore that needs to call a method from the store:

withFeature(
    // 👇 has full access to the store
    (store) => withEntityLoader((id) => firstValueFrom(store.load(id)))
)
Enter fullscreen mode Exit fullscreen mode

It looks pretty okay, but I don't get the indentation that withFeature => withEntityLoader introduces. We lose some readability, and withFeature doesn't really bring much value, except to help the developer integrate their feature.

My goal was to simplify the code so you can write withEntityLoader directly without withFeature (and I did it):

signalStore(
  withMethods((store) => ({
    load(id: number): Observable<Entity> {
      return of({ id, name: 'John' });
    },
  })),
  withEntityLoader((store) => firstValueFrom(store.load(id)))
);
Enter fullscreen mode Exit fullscreen mode

Still ✅ Fully typesafe.

stackblitz / github link available at the end of the article.

While working on adding a server-state management tool into the signalStore, I learned a lot about how typing works and how to offer the best DX to the user of our feature.

I explored several ways for you to benefit from these mechanisms in your day-to-day work without having to worry about typings.

That’s the promise I’m offering with the first example I’ll show you below. But I also considered those who want to go further and provide a top-notch user experience.

While building my server-state management tool, I realized it’s sometimes necessary to create highly typed configs based on the existing store content, which simplify the DX through things like autocompletion or optional functions.

To achieve this, I had to wrestle with TypeScript’s typing limitations—and more importantly, learn how to work around them—while minimizing the number of types the user needs to manually specify so TypeScript’s inference can do its job.

I won’t go into further detail on typing, but if you're interested, feel free to send me a message or leave a comment.

However, if you’ve already tried this kind of approach and struggled, know that I did too—it was tough—and I learned this lesson:

To add a feature to the signalStore, you need to explicitly type what your feature returns.

That might not mean much now, but every method I’ll present to achieve this follows that rule.

Example:

// 👇 increment result is inferred by TS, it knows it will return a number
function increment(count: number) {
 return count + 1;
}

// increment result is explicitly defined (TS will just check if the returned type matches the signature)
function increment(count: number): number 👈 {
 return count + 1;
}

Enter fullscreen mode Exit fullscreen mode

Case 1: 🎁 It's a gift! Just copy-paste and it works (thanks withFeatureFactory, maybe coming to NgRx?)

Here comes the bomb—I was so excited when I found this system because it allows us to easily achieve the goal!

Here’s the result when integrating our feature into the signalStore:

NgRx withfeature using withFeatureFactory

Here’s how I simply created withBooksFilter1 thanks to withFeatureFactory:

NgRx withBooksFilter1 using withFeatureFactory

If there’s one thing to remember, it’s the use of withFeatureFactory:

NgRx withFeatureFactory implemntation

It’s really awesome because we just pass our feature as input to withFeatureFactory, and typing magic follows.

It may not be visible at first glance, but everything is well typed.

I even added type tests to make sure of it:

NgRx withFeatureFactory Test

It’s so convenient that I think it could be a great addition to withFeature. I’m considering making a PR as soon as I have time (my first one 😄).

Note: You can only pass one parameter to your feature—feel free to wrap it in an object to bypass this limitation.

Let’s move on to the second case!

Case 2: 🎛️ Gives you more control and customization over your feature creation

The result in the signalStore is identical to the previous case.

However, here I’m offering a more explicit approach, making it easier to evolve your feature.

I created several utilities so you can use them without risk of breaking things too easily (and oh boy, typings break for the smallest reasons).

NgRx customizable signalStorefeature

Just keep the template and replace filterBooksFeature with your feature.

Note: You can only pass one parameter to your feature—feel free to wrap it in an object to bypass this limitation.

Now let’s dive into the advanced cases that fully leverage TypeScript's typing!

Case 3: 🚀 For a highly customizable solution—your coworkers will be speechless

Guaranteed wow effect—this technique requires some typing knowledge to customize.

As you can see, the user has access to the store (even if not 100% useful here), but the real benefit is enjoying specific autocomplete to indicate which book lists to apply the filter feature to.

This pattern is essential for more elaborate configurations where you want to extract the type of a config output and reuse it within the same config.

Here’s a fictional example of what I mean:

withBooksFilter3((store) =>
        booksSelector({
          booksPath: 'entities',
          fetchAllFilters: resource({
            loader: () => apiService.allFilters(),
          }),
          //👇 this mapper is only needed if fetchedFilters are not the same type as filters from our feature
          filtersMapper: (fetchedFilters) => toFeatureFilters(fetchedFilters),
          //              👆 the type of fetchedFilters is inferred from fetchAllFilters above
        })
);

Enter fullscreen mode Exit fullscreen mode

From a parent function, if there are functions returned in the response, you can't propagate their types to other fields. TypeScript loses track. To work around this, you need to use an intermediate function—like booksSelector here—which serves as a bridge and allows reusing the type returned by fetchAllFilters in the filtersMapper.

Up next 👇

Case 4: 🤯 Whoa, a feature with autocomplete and no intermediate function!

NgRx advanced signalStore feature result

Here, we don’t have the previous constraint, so we can skip the intermediate function.

Here’s the implementation of withBooksFilter4:
NgRx advanced signalStore feature implementation

This is a variant I wanted to keep in my back pocket, because I really like this approach too—it remains “simple” while making it easy to customize the user’s config.

🔗 Code Link! Stackblitz and Github

To test and see for yourself, here’s the stackblitz

This doesn’t launch the Angular app, but the suite of relevant tests.

GitHub repo here.

Conclusion

I’ve experimented with many approaches to find different ways to reach the goal. Maybe I haven’t found them all! I hope to see more solutions from others too!

In the meantime, I’d love to see more utility features like withLinkedState / withResources... It’s much easier to build now using the techniques I shared here.

Why not withServices (to explicitly define services), or withActionSource (like methods, but to benefit from declarative/reactive programming)?

We could have stores that are truly self-documenting, and that’s a great thing!

Until then, I hope you enjoyed these patterns. Feel free to drop a comment to say thanks or leave a like (it helps and motivates me to keep creating content 👍).

If you don’t know me, I’m Romain Geffrault, and I regularly share Angular/TypeScript/RxJs/Signal content. Check out my other articles and follow me on LinkedInRomain Geffrault

Top comments (0)