npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

signalstore-toolkit

v0.2.0

Published

Production-grade utilities for NgRx Signal Store — request status, entity sync, pagination, API method factories, and search/filter pipelines.

Readme

signalstore-toolkit

Production-grade utilities for NgRx Signal Store. Eliminates the boilerplate that every real-world signal store repeats — loading states, entity sync, pagination, API calls, and search/filter pipelines.

Born from patterns battle-tested across 20+ stores in a production hospitality platform.

Install

npm install signalstore-toolkit

Peer dependencies: @angular/core >=17, @ngrx/signals >=17, rxjs >=7, @ngrx/operators >=17 (optional — only needed for createApiMethod)

Features

| Feature | What it replaces | Lines saved | |---------|-----------------|-------------| | withRequestStatus | Per-operation isLoading / isError / errorMessage boilerplate | ~45 lines per store | | withEntitySync | Entity CRUD + live-update reconciliation | ~40 lines per store | | withPagination | Page state, computed helpers, navigation methods | ~35 lines per store | | createApiMethod | rxMethod + tap + switchMap + tapResponse wiring | ~30 lines per method | | withSearchFilter | Search + filter + sort computed pipeline | ~25 lines per store | | withSelectedEntity | Selected entity tracking with selectedId + selectedEntity computed | ~15 lines per store | | withPerOperationStatus | Per-operation {op}Loading / {op}Error for N named operations | ~50 lines per action store | | withResetState | Reset store to initial state (logout, route change, form reset) | ~10 lines per store | | withOptimisticUpdate | Snapshot → mutate → rollback-on-error pattern | ~25 lines per mutation | | mockSignalStore | Unit test mocks with auto-spy detection (vitest/jest) | ~30 lines per test file |


withRequestStatus

Tracks the lifecycle of an async operation: idle → pending → fulfilled | error.

import { signalStore, withMethods, patchState } from '@ngrx/signals';
import { withRequestStatus } from 'signalstore-toolkit';

const TodoStore = signalStore(
  { providedIn: 'root' },
  withRequestStatus(),
  withMethods((store) => ({
    load: rxMethod<void>(
      pipe(
        tap(() => store.setPending()),
        switchMap(() =>
          todoService.getAll().pipe(
            tapResponse({
              next: (todos) => {
                patchState(store, { todos });
                store.setFulfilled();
              },
              error: (e) => store.setError(e.message),
            }),
          ),
        ),
      ),
    ),
  })),
);

In your template:

@if (store.isPending()) {
  <loading-spinner />
}
@if (store.error(); as error) {
  <error-banner [message]="error" />
}

API

| Member | Type | Description | |--------|------|-------------| | requestStatus | Signal<RequestStatus> | Raw status value | | isPending | Signal<boolean> | true while loading | | isFulfilled | Signal<boolean> | true after success | | error | Signal<string \| null> | Error message or null | | setPending() | method | Transition to loading | | setFulfilled() | method | Transition to success | | setError(msg) | method | Transition to error | | resetStatus() | method | Back to idle |


withEntitySync

Real-time entity reconciliation. Adds syncAll, upsertOne, liveUpdate, and removeOne to any entity store. Compose it after withEntities().

import { signalStore, type } from '@ngrx/signals';
import { withEntities } from '@ngrx/signals/entities';
import { withEntitySync } from 'signalstore-toolkit';

interface Product {
  productId: string;
  name: string;
  qty: number;
}

const InventoryStore = signalStore(
  { providedIn: 'root' },
  withEntities({ entity: type<Product>(), collection: 'inventory' }),
  withEntitySync<Product>({
    selectId: (p) => p.productId,
    collection: 'inventory',
  }),
);
// Replace all entities from an API response
store.syncAll(productsFromApi);

// Upsert from a WebSocket / Firestore stream
store.upsertOne(incomingProduct);

// Parse JSON and upsert (for raw message payloads)
store.liveUpdate(websocketEvent.payload);

// Remove
store.removeOne('product-123');

API

| Method | Description | |--------|-------------| | syncAll(entities) | Replace the entire collection | | upsertOne(entity) | Update if exists, add if new | | liveUpdate(json) | JSON.parse → upsert (logs parse errors) | | removeOne(id) | Remove by entity ID |


withPagination

Page-based pagination with computed navigation helpers.

import { signalStore, withState, withMethods } from '@ngrx/signals';
import { withPagination } from 'signalstore-toolkit';

const ListStore = signalStore(
  { providedIn: 'root' },
  withState({ items: [] as Item[] }),
  withPagination({ pageSize: 25 }),
  withMethods((store) => ({
    load: rxMethod<void>(
      pipe(
        switchMap(() =>
          api.list({ offset: store.pageOffset(), limit: store.pageSize() }).pipe(
            tapResponse({
              next: (res) => {
                patchState(store, { items: res.items });
                store.setPageResult({ total: res.total });
              },
              error: console.error,
            }),
          ),
        ),
      ),
    ),
  })),
);
<button [disabled]="!store.hasPreviousPage()" (click)="store.previousPage()">Prev</button>
<span>{{ store.currentPage() }} / {{ store.totalPages() }}</span>
<button [disabled]="!store.hasNextPage()" (click)="store.nextPage()">Next</button>

API

| Member | Type | Description | |--------|------|-------------| | currentPage | Signal<number> | 1-indexed current page | | pageSize | Signal<number> | Items per page | | total | Signal<number> | Total item count from server | | totalPages | Signal<number> | Computed ceil(total / pageSize) | | hasNextPage | Signal<boolean> | currentPage < totalPages | | hasPreviousPage | Signal<boolean> | currentPage > 1 | | pageOffset | Signal<number> | (currentPage - 1) * pageSize | | setPage(n) | method | Jump to page (clamped) | | nextPage() | method | Go forward (no-op at end) | | previousPage() | method | Go back (no-op at start) | | setPageSize(n) | method | Change page size (resets to page 1) | | setPageResult({total}) | method | Update total from API response |


createApiMethod

Factory that creates an rxMethod pre-wired with loading state, response-status checking, and error handling. Replaces 30+ lines of pipe(tap, switchMap, tapResponse) boilerplate per method.

Requires @ngrx/operators peer dependency for tapResponse.

import { signalStore, withState, withMethods } from '@ngrx/signals';
import { createApiMethod } from 'signalstore-toolkit';
import { concatMap } from 'rxjs';

const TodoStore = signalStore(
  { providedIn: 'root' },
  withState({
    todos: [] as Todo[],
    isLoading: false,
    isError: false,
    errorMessage: '',
  }),
  withMethods((store) => ({
    // Read — uses switchMap (default, latest-wins)
    load: createApiMethod({
      store,
      request: () => todoService.getAll(),
      isSuccess: (res) => !!res.status,
      getErrorMessage: (res) => res.message ?? 'Load failed',
      onSuccess: (res) => ({ todos: res.data.toObject().items }),
    }),

    // Write — uses concatMap (queue, don't cancel)
    create: createApiMethod({
      store,
      request: (input: CreateTodoInput) => todoService.create(input),
      operator: concatMap,
      loadingKey: 'isCreating',
      errorKey: 'createError',
      messageKey: 'createErrorMessage',
      onSuccess: (res) => {
        // Manually patch if you need custom logic
        patchState(store, { todos: [...store.todos(), res.data.toObject()] });
      },
    }),
  })),
);

Config

| Option | Default | Description | |--------|---------|-------------| | store | required | Signal store instance | | request | required | (input) => Observable<Response> | | onSuccess | required | Handle success — return partial state or void | | onError | — | Handle error — return partial state or void | | isSuccess | — | App-level success check (e.g. res.status === true) | | getErrorMessage | — | Extract error message from failed isSuccess | | loadingKey | 'isLoading' | State key for loading boolean | | errorKey | 'isError' | State key for error flag | | messageKey | 'errorMessage' | State key for error message | | operator | switchMap | RxJS flattening operator |


withSearchFilter

Search + sort pipeline on entity collections. Compose after withEntities().

import { signalStore, type } from '@ngrx/signals';
import { withEntities } from '@ngrx/signals/entities';
import { withSearchFilter } from 'signalstore-toolkit';

const RoomStore = signalStore(
  { providedIn: 'root' },
  withEntities({ entity: type<Room>() }),
  withSearchFilter<Room>({
    searchFields: (r) => [r.name, r.floor, r.type],
    sortBy: (a, b) => a.name.localeCompare(b.name),
  }),
);
<ion-searchbar (ionInput)="store.setSearchQuery($event.detail.value ?? '')" />

@for (room of store.filteredEntities(); track room.id) {
  <room-card [room]="room" />
} @empty {
  <p>No rooms match "{{ store.searchQuery() }}"</p>
}

API

| Member | Type | Description | |--------|------|-------------| | searchQuery | Signal<string> | Current search term | | filteredEntities | Signal<Entity[]> | Filtered + sorted result | | setSearchQuery(q) | method | Update the search term | | clearSearch() | method | Reset to empty string |


Composing Features

These features are designed to compose together. Here's a complete store using all five:

const ProductStore = signalStore(
  { providedIn: 'root' },

  // Entities
  withEntities({ entity: type<Product>() }),

  // Request status
  withRequestStatus(),

  // Pagination
  withPagination({ pageSize: 50 }),

  // Real-time sync
  withEntitySync<Product>({ selectId: (p) => p.id }),

  // Search
  withSearchFilter<Product>({
    searchFields: (p) => [p.name, p.sku, p.category],
    sortBy: (a, b) => a.name.localeCompare(b.name),
  }),

  // API methods
  withMethods((store) => ({
    load: createApiMethod({
      store,
      request: (params: ListParams) => productService.list(params),
      onSuccess: (res) => {
        store.syncAll(res.items);
        store.setPageResult({ total: res.total });
      },
    }),
  })),
);

withSelectedEntity

Track which entity is selected in a list/detail view. Compose after withEntities().

const TodoStore = signalStore(
  { providedIn: 'root' },
  withEntities({ entity: type<Todo>() }),
  withSelectedEntity(),
);

store.select('todo-1');
store.selectedEntity();   // Todo | null
store.clearSelection();

For named collections: withSelectedEntity({ collection: 'products' }).

API

| Member | Type | Description | |--------|------|-------------| | selectedId | Signal<EntityId \| null> | Currently selected ID | | selectedEntity | Signal<Entity \| null> | Computed from entityMap | | select(id) | method | Set selection | | clearSelection() | method | Clear selection |


withPerOperationStatus

Generates independent loading/error tracking for N named async operations. The unique feature nobody else ships.

const TaskActionStore = signalStore(
  { providedIn: 'root' },
  withPerOperationStatus({ operations: ['load', 'save', 'delete'] as const }),
  withMethods((store) => ({
    loadTasks: rxMethod<void>(
      pipe(
        tap(() => store.startOp('load')),
        switchMap(() =>
          taskService.list().pipe(
            tapResponse({
              next: (tasks) => { store.endOp('load'); },
              error: (e) => store.failOp('load', e.message),
            }),
          ),
        ),
      ),
    ),
  })),
);
@if (store.loadState().loading) { <spinner /> }
@if (store.saveState().error; as err) { <error [message]="err" /> }

Generated per operation

For each operation name (e.g. 'load'):

| Member | Type | Description | |--------|------|-------------| | loadLoading | Signal<boolean> | Loading flag | | loadError | Signal<string \| null> | Error message | | loadState | Signal<{ loading, error }> | Combined status |

Helper methods

| Method | Description | |--------|-------------| | startOp(name) | Set loading true, clear error | | endOp(name) | Set loading false | | failOp(name, msg) | Set loading false, set error |


withResetState

Captures initial state on store creation, adds resetState() to restore it. Useful for logout, route changes, or form resets.

const FormStore = signalStore(
  { providedIn: 'root' },
  withState({ name: '', email: '', dirty: false }),
  withResetState(),
);

// After user edits:
store.resetState(); // back to { name: '', email: '', dirty: false }

Must be composed after all withState() calls.


withOptimisticUpdate

Applies a local mutation immediately, then rolls back if the server request fails.

const TodoStore = signalStore(
  { providedIn: 'root' },
  withState({ todos: [] as Todo[] }),
  withOptimisticUpdate(),
  withMethods((store) => ({
    toggleDone(id: string) {
      store.optimistic(
        // 1. Mutate locally (instant UI)
        () => patchState(store, {
          todos: store.todos().map(t =>
            t.id === id ? { ...t, done: !t.done } : t
          ),
        }),
        // 2. Confirm on server
        todoService.toggle(id),
        {
          onError: (err) => console.error('Rolled back', err),
        },
      );
    },
  })),
);

API

| Parameter | Type | Description | |-----------|------|-------------| | mutation | () => void | Function that calls patchState immediately | | request | Observable<T> | Server request to confirm the mutation | | options.onSuccess | (res) => void | Called on success | | options.onError | (err) => void | Called after rollback on failure |


mockSignalStore

Testing utility that creates a mock of any signal store. Auto-detects vitest (vi.fn()) or jest (jest.fn()) for spies.

import { mockSignalStore } from 'signalstore-toolkit';

const mock = mockSignalStore(TodoStore, {
  entities: [{ id: '1', title: 'Test', done: false }],
  isPending: false,
  error: null,
});

// Signals work like the real store:
mock.isPending();       // false
mock.entities();        // [{ id: '1', ... }]

// Methods are auto-spied:
mock.load();
expect(mock.load).toHaveBeenCalled();

// Update signal values in tests:
(mock.isPending as WritableSignal<boolean>).set(true);

With TestBed

TestBed.configureTestingModule({
  providers: [
    { provide: TodoStore, useValue: mockSignalStore(TodoStore, { ... }) },
  ],
});

Config

| Option | Default | Description | |--------|---------|-------------| | spyFn | auto-detect | Custom spy factory: () => vi.fn() |


vs @angular-architects/ngrx-toolkit

| Feature | signalstore-toolkit | @angular-architects/ngrx-toolkit | |---------|--------------------|---------------------------------| | Request status tracking | withRequestStatus() -- idle/pending/fulfilled/error lifecycle | withCallState() -- similar concept | | Per-operation status | Use multiple withRequestStatus() with different keys (planned v0.2) | Single call state per feature | | Entity CRUD + live sync | withEntitySync() -- syncAll, upsertOne, liveUpdate from JSON | Not included | | Pagination | withPagination() -- full page state + navigation | Not included | | API method factory | createApiMethod() -- rxMethod + tapResponse + loading in one config | withDataService() -- different approach, couples to a service class | | Search/filter pipeline | withSearchFilter() -- declarative search + sort on entities | Not included | | Per-operation status | withPerOperationStatus() -- N named operations | Not included | | Selected entity | withSelectedEntity() -- selectedId + selectedEntity | Not included | | Reset state | withResetState() -- restore initial state | Not included | | Optimistic updates | withOptimisticUpdate() -- snapshot + rollback | Not included | | Test mocks | mockSignalStore() -- auto-spy for vitest/jest | Not included | | DevTools integration | Not included (use ngrx-toolkit for this) | withDevtools() | | Undo/redo | Not included | withUndoRedo() | | Storage sync | Not included | withStorageSync() |

The two libraries are complementary -- use both together for full coverage.


Examples

See examples/product-store.ts for a complete store using all 5 features.


Compatibility

| Dependency | Minimum version | |-----------|----------------| | Angular | 17+ | | NgRx Signals | 17+ | | RxJS | 7+ | | @ngrx/operators | 17+ (optional — only for createApiMethod) |

License

MIT