skip to content

In an NgRx SignalStore, when a reusable paging feature needs the host store's loader, when do you declare a typed input and when use withFeature?

level: seniorimportance: should knowfreq 18%

answer

  1. features that depend on their host
  2. type helper as first argument
  3. state, props and methods constraints
  4. withFeature receives the store so far
  5. coupling by member name

basics

~20 s

A typed input, signalStoreFeature({ methods: type<{ loadPage(i: number): void }>() }, ...), makes the host declare loadPage or fail to compile. withFeature((store) => withPaging((i) => store.fetchUsers(i))) passes the dependency explicitly and keeps the feature independent of host names.

solid answer

~40 s

`signalStoreFeature` can take a first argument built with `type<...>()` that lists the `state`, `props` or `methods` the host must already have; inside the feature they are typed, and a store without them fails to compile. That couples the feature to the host's member names. `withFeature` instead takes a callback that receives the store built so far and returns a feature, so the store passes what the feature needs as ordinary arguments: `withFeature((store) => withPageNavigation((i) => store.fetchUsers(i)))`. I use a typed input when the feature extends a known shape such as `EntityState`, and `withFeature` when it just needs a function or value. Both only see members declared before them.

code

ts · 15 lines
ts
import { patchState, signalStoreFeature, type, withMethods, withState } from '@ngrx/signals';

// Typed input: the host store must already declare loadPage(pageIndex)
export function withPageNavigation<_>() {
  return signalStoreFeature(
    { methods: type<{ loadPage(pageIndex: number): void }>() },
    withState({ pageIndex: 0 }),
    withMethods((store) => ({
      nextPage(): void {
        patchState(store, { pageIndex: store.pageIndex() + 1 });
        store.loadPage(store.pageIndex());
      },
    }))
  );
}

go deeper

for a junior

Recall that a custom feature can depend on its host store, either by declaring a typed input or by being wrapped in withFeature.

for a middle

Explain the state, props and methods input constraints built with type(), and that withFeature's callback receives the members declared before it.

for a senior

Choose deliberately: typed inputs for features defined on a known shape, withFeature to avoid name coupling, and know the unused-generic workaround and feature ordering rules.

for a principal

Treat feature dependencies as public API: decide which shared features may constrain host stores, and keep coupling low so stores can evolve independently.

## The problem: a feature that needs its host A custom SignalStore feature built with `signalStoreFeature` is easiest to reuse when it owns everything it touches. Real features often need something from the store they are added to. Take a `withPageNavigation()` feature that adds `pageIndex` plus `nextPage()` and `previousPage()` methods for both a products store and a users store. Moving to the next page is shared logic, but **loading** that page is not: the products store calls a products API and the users store calls a users API. The feature needs the host's loader. NgRx 22 gives two ways to express that dependency: a **typed input** on the feature, and the `withFeature` utility. ## Option 1: typed input on signalStoreFeature `signalStoreFeature` accepts an optional first argument describing what the host must already contain. It is built with the `type` helper from `@ngrx/signals`: - `{ state: type<...>() }` — state slices the host must have; - `{ props: type<...>() }` — properties, such as signals or services exposed with `withProps`; - `{ methods: type<...>() }` — methods, such as `loadPage(pageIndex: number): void`. Inside the feature those members are available and fully typed. If a store adds the feature without them, the `signalStore(...)` call **fails to compile** — the error surfaces at the store, not at runtime. `SignalStoreFeatureType<typeof withX>` can extract one feature's shape to reuse as another feature's input. The cost is **coupling by name**: the feature dictates that the host's method is called `loadPage`. A users store whose loader is `fetchUsers` must rename it or add an adapter method. The docs recommend loosely coupled, independent features where possible. ## Option 2: withFeature `withFeature` (added in NgRx 19.1) takes a callback that receives the store built so far — its state signals, props and methods, plus the writable state source — and returns a feature. The feature itself declares no input; it takes plain function arguments instead: 1. Write `withPageNavigation(loadPage: (pageIndex: number) => void)` as an ordinary feature with no typed input. 2. In each store, add `withFeature((store) => withPageNavigation((i) => store.fetchUsers(i)))` after the features that define `fetchUsers`. 3. The feature calls whatever it was given, and never learns the host's member names. This keeps the feature **independent of the host's internal structure**, and it works for inputs that are not members at all: a computed value, a service call or a constant. ## Choosing between them | Concern | Typed input (`type<...>()`) | `withFeature` | |---|---|---| | Where the dependency is declared | In the feature's signature | At the store, in the callback | | Coupling to host names | High: host must use the exact names | None: host passes values explicitly | | Compile-time safety | Yes, the store fails to compile | Yes, the callback is typed | | Best for | Features that genuinely extend a known shape, e.g. `EntityState` | Features that need a callback or a value | | Readability at the store | Hidden: you must open the feature to see its needs | Visible in the store definition | A rule that works well: use a typed input when the feature is defined **in terms of** a well-known shape (a selection feature on top of `EntityState<Entity>`), and `withFeature` when the feature just needs a function or value from the host. ## Traps worth knowing - **Order still applies.** Both approaches only see members declared **before** them in the `signalStore(...)` argument list. - **A known TypeScript issue.** Combining several custom features that have static input but no generic parameter can produce unexpected compile errors. The docs' workaround is an unused generic: `function withZ<_>() { ... }`. - **Deep signals in generic features.** Since NgRx 22, a generic custom feature sees a state slice such as `Entity | null` as `DeepSignalOf<Entity | null>` rather than a plain `Signal`, because unions containing object literals now produce deep signals. - **Do not reach for inheritance.** Neither option needs a base class; if a feature needs half the host store, it is probably two features. - **Hide the feature's internals.** Members whose names start with `_` are private to the store, so a feature can keep helper state or methods out of the public API that components see. ## Testing each style A feature with a typed input is tested by building a tiny `signalStore(...)` that supplies the required members, often as stubs, then adds the feature. A feature used through `withFeature` is easier: its dependency is an ordinary function argument, so the test passes a spy such as a recorded list of requested page indexes and asserts on it, with no fake host members at all. That testability difference is a practical reason teams lean towards `withFeature` for features that call back into their host.

  • Why do the NgRx docs suggest adding an unused generic, such as withZ<_>(), to some custom features?
    Combining several custom features that declare a static input but have no generic parameter can trigger unexpected TypeScript compilation errors in the `signalStore(...)` call. Declaring an unused generic on each such feature factory avoids the issue. It is a typing workaround only; the runtime behaviour does not change.
  • How can one custom feature require the members another custom feature adds, without repeating their types?
    Use `SignalStoreFeatureType<typeof withRequestStatus>` to extract that feature's state, props and methods, then pass `type<ThatType>()` as the input of the second feature. The second feature then compiles only in stores that already include the first one, and the two stay in sync when the first one changes.

saying these in an interview costs you the question

  • A missing typed input is reported only at runtime, when the method is first called
  • withFeature's callback can see members declared after it in signalStore
  • Typed inputs are the only way a feature can call a host method
  • Features that need host members should extend a shared base store class
  • withFeature is a renamed signalStoreFeature with the same behaviour