skip to content

With an NgRx SignalStore, how do you share loading and paging logic between a products store and a users store without a base class?

level: middleimportance: must knowfreq 34%

answer

  1. composition, not inheritance
  2. a function that returns a feature
  3. signalStoreFeature merges built-in features
  4. standalone updaters for patchState
  5. each store gets its own copy

basics

~10 s

Write a withPaging() function that returns signalStoreFeature(withState(...), withComputed(...)) for the page index, loading flag and error, then add it to both signalStore() calls. Each store gets its own independent copy of that state.

solid answer

~30 s

A SignalStore is assembled from features, so reuse is composition, not inheritance. I write `withPaging()` returning `signalStoreFeature(...)` that combines `withState` for `pageIndex`, `pageSize`, `loading` and `error` with `withComputed` for derived values like `offset`. `ProductsStore` and `UsersStore` each list `withPaging()` next to their own state and `loadPage` method, and every store instance gets its own copy of that slice. Following the NgRx docs, I export the transitions as standalone updaters such as `setLoading()` and `setPage(n)` instead of feature methods: they tree-shake, test as pure functions and combine in one `patchState` call. Order matters, because a feature only sees members declared before it.

code

ts · 23 lines
ts
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';

export type PagingState = {
  pageIndex: number;
  pageSize: number;
  loading: boolean;
  error: string | null;
};

export function withPaging(pageSize = 20) {
  return signalStoreFeature(
    withState<PagingState>({ pageIndex: 0, pageSize, loading: false, error: null }),
    withComputed(({ pageIndex, pageSize }) => ({
      offset: computed(() => pageIndex() * pageSize()),
    }))
  );
}

export const setPage = (pageIndex: number): Partial<PagingState> => ({ pageIndex });
export const setLoading = (): Partial<PagingState> => ({ loading: true, error: null });
export const setLoaded = (): Partial<PagingState> => ({ loading: false });
export const setFailed = (error: string): Partial<PagingState> => ({ loading: false, error });

go deeper

for a junior

Recall that SignalStores are built from with-features and that signalStoreFeature bundles several of them into one reusable piece you add to any store.

for a middle

Explain that features apply in order, each store instance gets its own copy of the feature's state, and why standalone updaters beat feature methods for patchState.

for a senior

Show judgment about feature boundaries: small single-concern features, explicit dependencies on the host, no name clashes, and no giant base feature that recreates inheritance.

for a principal

Frame custom features as the team's reuse contract: which cross-cutting concerns get a shared feature, who owns it, and how changes to it are rolled out across stores.

## Why composition instead of a base class An NgRx **SignalStore** is not built by subclassing. `signalStore(...)` takes a list of **features** — `withState`, `withComputed`, `withMethods`, `withHooks`, `withProps` and so on — and applies them in order, each one adding members to the store it is building. That makes **composition** the natural way to reuse logic: instead of a `BasePagedStore` class that `ProductsStore` and `UsersStore` extend, you write a function that returns a ready-made bundle of features and drop it into both stores. The function that builds such a bundle is `signalStoreFeature`, exported from `@ngrx/signals`. It accepts a sequence of built-in or custom features and merges them into **one feature** that `signalStore(...)` accepts exactly like a built-in one. ## Building the shared loading and paging feature For a products list and a users list that both load one page at a time, the shared part is the same: a page index, a page size, a loading flag and an error. The steps are: 1. Define the state type for the slice the feature owns (`pageIndex`, `pageSize`, `loading`, `error`). 2. Write a factory function, by convention named `withSomething()`, that returns `signalStoreFeature(...)`. 3. Inside it, add `withState` for the slice and `withComputed` for derived values such as `offset` (`pageIndex * pageSize`). 4. Export small **state updaters** — plain functions returning a partial state — for the transitions: `setLoading()`, `setLoaded()`, `setPage(n)`. 5. Add `withPaging()` to each `signalStore(...)` call, next to that store's own state and methods. The factory can take arguments (`withPaging(24)` for a product grid, `withPaging(50)` for a user table), because it is an ordinary function. ## Standalone updaters rather than feature methods The NgRx docs recommend defining a custom feature's state updaters as **standalone functions** instead of methods inside the feature. Three reasons: - **Tree-shaking** — an updater nobody imports is dropped from the bundle; a method is always a member of the store. - **Testing** — an updater is a pure function from state to partial state, testable without creating a store. - **Composition** — several updaters go into **one** `patchState` call, for example `patchState(store, setAllEntities(products), setLoaded())`. `patchState` folds all of them into one new state and sets only the slices whose values changed, so the store changes once, not twice. ## What each store ends up with | Member | Comes from | Products store | Users store | |---|---|---|---| | `pageIndex`, `pageSize`, `loading`, `error` | `withPaging()` state | own copy | own copy | | `offset` | `withPaging()` computed | own copy | own copy | | product list, `loadPage()` | the products store's own features | yes | no | | user list, `loadPage()` | the users store's own features | no | yes | The feature is a **recipe, not a shared instance**. Each store — and each instance of a store provided at component level — runs the recipe when it is created and gets independent state. Nothing is shared between the two stores unless you put a shared service behind them. ## Pitfalls interviewers probe - **Order matters.** Features are applied left to right, and a feature sees only the members declared before it. A `withMethods` that reads `store.offset()` must come after `withPaging()`. - **Name clashes.** If the host store already declares `loading`, adding the feature's `loading` makes SignalStore log a dev-mode warning that store members cannot be overridden. Pick names that belong to the feature, or make internal ones private with a `_` prefix. - **Hidden coupling.** A feature that silently expects the host to have a `products` slice is fragile. When a feature needs something from its host, declare it explicitly with a typed input or pass it in with `withFeature` (a separate topic). - **Keep it small.** Prefer loosely coupled features that own one concern — paging, request status, selection — over one large "base store" feature that reintroduces the inheritance problem under another name. ## When a custom feature is the wrong tool - **Only one store needs it.** Extracting a feature for a single store adds a file and an indirection for no reuse; keep the code inline until a second store needs it. - **The two copies would diverge.** If products paginate by cursor and users by page number, one feature with flags for both is worse than two small features. - **The state must really be shared.** A feature gives every store its own copy. When the products page and a header badge must read the *same* loading state, that state belongs in one store (for example a root-provided one) that both inject, not in a feature they both include. The result is the same reuse a base class would give, without an inheritance chain: every store states in its own `signalStore(...)` call which capabilities it has.

  • Why export setLoading() as a standalone updater instead of a method inside the feature?
    The NgRx docs recommend standalone updaters for custom features. They are tree-shakable, testable as pure functions without a store, and several of them fit into one `patchState(store, setAllEntities(items), setLoaded())` call. `patchState` folds all updaters into one new state and sets only the slices that changed, so the store changes once instead of once per method call.
  • What happens if the products store already has its own loading state slice when you add withPaging()?
    Both features try to define a `loading` member, and SignalStore logs a dev-mode warning that store members cannot be overridden. The fix is naming: give feature members names that belong to the feature, or make internal members private with a `_` prefix so they never collide with the host's public API.

saying these in an interview costs you the question

  • Extending a base store class is the standard way to reuse SignalStore logic
  • signalStoreFeature creates one shared state instance used by every store
  • A custom feature can read members that are declared after it
  • Feature methods cannot call patchState, so updaters must live outside
  • A feature can quietly rely on host state without declaring it