skip to content

In NgRx, how would a date-range picker keep its own state in a ComponentStore, and how do updater, select and effect divide the work?

level: seniorimportance: should knowfreq 33%

answer

  1. one store per component instance
  2. provided by the component, not root
  3. write, read, side effect
  4. effect lives as long as the store
  5. catch errors inside, not outside

basics

~20 s

Provide a ComponentStore subclass in the picker's providers so each picker gets its own store that is torn down with it; updater writes state, select exposes deduplicated Observables, and effect runs async work such as loading blocked days.

solid answer

~40 s

`DateRangeStore extends ComponentStore<RangeState>` is `@Injectable()` without `providedIn`, and the picker lists it in `providers`, so two pickers never share a range and the store's `ngOnDestroy` completes its streams with the component. `updater((state, day) => next)` returns a function that accepts a value or an Observable and writes the next state; it throws if the state was never initialized. `select(projector)` returns a shared Observable that skips values equal by `===`, with `{ debounce: true }` to wait for state to settle, and `selectSignal` gives a signal instead. `effect((day$) => ...)` subscribes once for the store's lifetime and returns a trigger function; you choose the flattening operator and handle errors inside it, with `tapResponse` from `@ngrx/operators` in NgRx 22. For new code the NgRx docs recommend `@ngrx/signals`; ComponentStore remains supported.

code

ts · 38 lines
ts
import { Injectable, inject } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { Observable, switchMap } from 'rxjs';
import { CalendarApi } from './calendar-api';

interface RangeState { start: string | null; end: string | null; blocked: string[] }

@Injectable()
export class DateRangeStore extends ComponentStore<RangeState> {
  private readonly calendar = inject(CalendarApi);

  constructor() {
    super({ start: null, end: null, blocked: [] });
  }

  readonly start$ = this.select((s) => s.start);
  readonly isComplete$ = this.select((s) => s.start !== null && s.end !== null);

  readonly pickDay = this.updater((s, day: string) =>
    s.start === null || s.end !== null || day < s.start
      ? { ...s, start: day, end: null }
      : { ...s, end: day }
  );

  readonly loadBlockedDays = this.effect((month$: Observable<string>) =>
    month$.pipe(
      switchMap((month) =>
        this.calendar.blockedDays(month).pipe(
          tapResponse({
            next: (blocked) => this.patchState({ blocked }),
            error: () => this.patchState({ blocked: [] }),
          })
        )
      )
    )
  );
}

go deeper

for a junior

Recall that a ComponentStore is a class provided by one component, and that updater writes, select reads and effect runs async work.

for a middle

Explain the lifecycle: provided per instance, torn down on destroy, effect pipelines subscribed once, updaters throwing before initialization.

for a senior

Diagnose effects that die on errors, selectors that emit on every change and hooks that never run, and say when a new component should use SignalStore instead.

for a principal

Decide which state is component-owned versus global, and plan a gradual move from ComponentStore to SignalStore without a big-bang rewrite.

## Why a component-scoped store fits a date-range picker A date-range picker holds state that belongs to **one instance**: the chosen start, the chosen end, the month on screen and the days that cannot be booked. A search form with "check-in" and "check-out" pickers, or two pickers on one comparison page, must not share it, and the state should vanish when the picker closes. That is what `@ngrx/component-store` is for: - **One store per instance.** The store class is `@Injectable()` with no `providedIn`. Listing it in the picker's `providers` gives each picker its own instance. - **Cleanup with the component.** When the picker is destroyed, the store's `ngOnDestroy` completes its state stream and fires `destroy$`, which ends every `select`, `updater` and `effect` subscription it created. - **No actions or reducers.** Unlike the global Store, there is no action indirection: components call the store's methods directly. The trade-off is that the global Store's DevTools history and one-action-many-listeners model do not apply. ## The three building blocks | Block | You write | It returns | Runs | |---|---|---|---| | `updater` | `(state, value) => newState` | a function taking a value or Observable | each time it is called or the Observable emits | | `select` | `(state) => derived` | an `Observable` of the derived value | when the result changes by `===` | | `effect` | `(source$) => Observable` | a function taking a value or Observable | the pipeline is subscribed once, for the store's life | A few details that interviewers probe: - `setState` and `patchState` are the ready-made writers; `updater` is for named, reusable transitions such as `pickDay`. - An `updater` called before the state exists **throws**. Initialize through the constructor (`super(initial)`) or with `setState` before any write. - `select` shares one upstream subscription and replays the latest value. Its config takes `debounce` (wait until the state settles) and `equal` (a custom comparison). - `selectSignal` builds a `computed` signal from the state, for templates that read signals. - `effect` gives you the source Observable; each call of the returned function pushes a value into it. ## Wiring the picker 1. Define `RangeState` and pass an initial value to `super()` in the store's constructor. 2. Add updaters: `pickDay` sets the start, then the end, and restarts when a day before the start is picked. 3. Add selectors: `start$`, `end$`, `isComplete$`, or `selectSignal` equivalents. 4. Add an effect `loadBlockedDays` that takes the visible month, uses `switchMap` so a quick month flip cancels the stale request, and handles the response with `tapResponse({ next, error })`. 5. In the picker component, add `providers: [DateRangeStore]`, inject it, call `loadBlockedDays(month)` on navigation and `pickDay(day)` on click. ## Pitfalls that show up in production - **An effect that dies.** If an HTTP error reaches the effect's outer stream, that stream errors and unsubscribes from its source. Later calls push values nobody listens to. ComponentStore has no resubscribing error handler like the one `@ngrx/effects` installs by default, so catch inside the inner Observable with `tapResponse` or `catchError`. - **Selectors that always emit.** `select((s) => ({ start: s.start, end: s.end }))` builds a new object on every state change, so the `===` check never suppresses anything. Select primitives, or pass `equal`. - **Lifecycle hooks that never run.** `OnStoreInit` and `OnStateInit` need `provideComponentStore(DateRangeStore)` in `providers`; listed plainly, the hooks are skipped and dev mode logs a warning. - **Old imports.** `tapResponse` was removed from `@ngrx/component-store` in NgRx 18; in NgRx 22 it comes from `@ngrx/operators` and takes an observer object only. ## ComponentStore today The NgRx documentation calls `@ngrx/signals` the new default for local state and encourages it for new projects, while stating that ComponentStore remains supported. The practical position for an interview: - Existing ComponentStores keep working; migrate when a feature is being reworked anyway. - A new picker would usually be written as a SignalStore, which exposes state as signals and is composed from functions instead of a base class. - The design question stays the same either way: state owned by one component instance, provided by that component, gone when it goes. - A candidate who can explain ComponentStore's updater, select and effect can usually map them straight onto the newer API, which is the migration an interviewer on an older codebase most wants to hear about.

  • The picker's loadBlockedDays effect stops responding after one failed request. Why?
    The effect's pipeline is subscribed once for the store's lifetime. An error that reaches its outer stream errors it and unsubscribes it from the source, so later calls push values nobody listens to, and RxJS reports the error as unhandled. ComponentStore has no resubscribing handler like the default one in `@ngrx/effects`. Catch inside the inner Observable with `tapResponse` or `catchError`.
  • When do you need provideComponentStore instead of listing the class in providers?
    When the store implements `OnStoreInit` or `OnStateInit`. `provideComponentStore(DateRangeStore)` registers a factory that calls `ngrxOnStoreInit` right after construction and `ngrxOnStateInit` once the state first exists. Listed plainly, the hooks never run and dev mode logs a warning that names `provideComponentStore`.
  • Why would a new picker usually be written with SignalStore?
    The NgRx docs name `@ngrx/signals` the new default for local state and encourage it for new projects, while ComponentStore stays supported. A SignalStore exposes state as signals and is composed from functions, which fits signal-based components without bridging Observables. Existing ComponentStores need no rushed rewrite.

saying these in an interview costs you the question

  • A ComponentStore should be providedIn: 'root' so every picker shares it.
  • ComponentStore resubscribes an effect after an error, like @ngrx/effects does.
  • An updater called before initialization just queues the value.
  • select emits on every state change, so projecting new objects is harmless.
  • tapResponse is imported from @ngrx/component-store.