DEV Community

Harsh Mathur
Harsh Mathur

Posted on

I cut 40% of my NgRx Signal Store boilerplate — here's the 5 utilities I extracted

Every Signal Store in our monorepo started the same way. Fifty-plus lines of ceremony before a single line of business logic. Loading flags, error messages, pagination math, search pipes, entity sync — all copy-pasted from the last store with minor tweaks. Twenty stores later, I had twenty copies of the same patterns drifting apart.

So I extracted them. Five composable signalStoreFeature utilities, each replacing a chunk of repetitive code with a single function call. The result is signalstore-toolkit.

The problem: stores that are 60% scaffolding

Here is a stripped-down version of what a typical store looked like before. This handles one entity with loading state, error handling, and pagination:

export const ProductStore = signalStore(
  withEntities<Product>(),
  withState({
    isLoading: false,
    isError: false,
    errorMessage: '',
    currentPage: 1,
    pageSize: 25,
    total: 0,
    searchQuery: '',
  }),
  withComputed((store) => ({
    totalPages: computed(() => Math.ceil(store.total() / store.pageSize())),
    hasNextPage: computed(() => store.currentPage() < Math.ceil(store.total() / store.pageSize())),
    hasPreviousPage: computed(() => store.currentPage() > 1),
    pageOffset: computed(() => (store.currentPage() - 1) * store.pageSize()),
    filteredEntities: computed(() => {
      const q = store.searchQuery().toLowerCase();
      return store.entities().filter(e => e.name.toLowerCase().includes(q));
    }),
  })),
  withMethods((store) => ({
    setPending: () => patchState(store, { isLoading: true, isError: false, errorMessage: '' }),
    setFulfilled: () => patchState(store, { isLoading: false }),
    setError: (msg: string) => patchState(store, { isLoading: false, isError: true, errorMessage: msg }),
    setPage: (page: number) => patchState(store, { currentPage: page }),
    nextPage: () => patchState(store, { currentPage: store.currentPage() + 1 }),
    setSearchQuery: (q: string) => patchState(store, { searchQuery: q }),
    syncAll: (products: Product[]) => patchState(store, setAllEntities(products)),
    upsertOne: (product: Product) => patchState(store, upsertEntity(product)),
    removeOne: (id: string) => patchState(store, removeEntity(id)),
  })),
);
Enter fullscreen mode Exit fullscreen mode

That is around 35 lines of infrastructure and maybe 0 lines of actual domain logic. Every new store repeated this. Every one was slightly different in the ways that cause bugs.

The fix: five composable features

withRequestStatus — the hero cut

This was the biggest win. Every store had its own isLoading, isError, errorMessage triple, its own setPending/setFulfilled/setError methods. withRequestStatus() replaces all of it:

// Before: 15 lines of state + methods per store
// After:
withRequestStatus()
Enter fullscreen mode Exit fullscreen mode

One line. You get requestStatus, isPending, isFulfilled, error, setPending, setFulfilled, setError, and resetStatus — all typed, all consistent across every store.

withEntitySync — real-time entity reconciliation

If you work with live data — WebSocket updates, polling, server-sent events — you need to reconcile incoming entities with what is already in the store. withEntitySync() gives you syncAll, upsertOne, liveUpdate (parses JSON and upserts), and removeOne:

withEntities<Product>(),
withEntitySync<Product>(),
Enter fullscreen mode Exit fullscreen mode

The liveUpdate method is particularly useful. Point it at a WebSocket message and it handles JSON parsing and entity upsert in one call.

withPagination — no more page math

Page offset calculations, hasNextPage guards, total page derivation — all computed, all reactive:

withPagination()
// Gives you: currentPage, pageSize, total, totalPages,
// hasNextPage, hasPreviousPage, pageOffset,
// setPage, nextPage, previousPage, setPageSize, setPageResult
Enter fullscreen mode Exit fullscreen mode

createApiMethod — factory for rxMethod + loading state

This is not a store feature but a factory function. It wires up rxMethod with tapResponse, manages loading/error state on the store, and validates the API response shape. You configure the operator (switchMap for reads, concatMap for writes):

loadProducts: createApiMethod({
  store,
  request: (params: PageParams) => inject(ProductApi).list(params),
  onSuccess: (res) => store.syncAll(res.products),
  operator: 'switch',
}),
Enter fullscreen mode Exit fullscreen mode

That replaces roughly 30 lines of rxMethod + pipe + tapResponse + manual setPending/setFulfilled/setError wiring per API call.

withSearchFilter — declarative search + sort

Composes after withEntities(). You declare which fields to search and how to sort:

withSearchFilter<Product>({
  searchFields: (p) => [p.name, p.sku],
  sortBy: (a, b) => a.name.localeCompare(b.name),
})
// Gives you: searchQuery, setSearchQuery, filteredEntities
Enter fullscreen mode Exit fullscreen mode

The composed store

Here is the same product store from above, rebuilt with all five utilities:

export const ProductStore = signalStore(
  withEntities<Product>(),
  withRequestStatus(),
  withEntitySync<Product>(),
  withPagination(),
  withSearchFilter<Product>({
    searchFields: (p) => [p.name, p.sku],
    sortBy: (a, b) => a.name.localeCompare(b.name),
  }),
  withMethods((store) => ({
    loadProducts: createApiMethod({
      store,
      request: (params: PageParams) => inject(ProductApi).list(params),
      onSuccess: (res) => {
        store.syncAll(res.items);
        store.setPageResult({ total: res.total });
      },
      operator: 'switch',
    }),
  })),
);
Enter fullscreen mode Exit fullscreen mode

All the infrastructure is handled. What remains is the domain-specific wiring: which API to call, what to do with the response. That is it.

How it compares to @angular-architects/ngrx-toolkit

The two libraries are complementary, not competing. @angular-architects/ngrx-toolkit focuses on DevTools integration, undo/redo, Redux connector, and data service patterns. signalstore-toolkit focuses on per-operation status tracking, entity live-update reconciliation, the API method factory with app-level response checking, and search/filter pipelines. There is no overlap — you can use both in the same store.

Install

npm install signalstore-toolkit
Enter fullscreen mode Exit fullscreen mode

Peer deps: Angular 17+, @ngrx/signals 17+, RxJS 7+. The @ngrx/operators package is optional and only needed if you use createApiMethod.

These patterns were extracted from a production Angular monorepo running 20+ Signal Stores with gRPC-web as the transport layer. They have been stable in production for months.

npm: signalstore-toolkit
GitHub: harsh04/signalstore-toolkit

Feedback welcome.

Top comments (0)