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)),
})),
);
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()
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>(),
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
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',
}),
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
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',
}),
})),
);
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
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)