NgRx ComponentStore — Lightweight Local State

📋 Table of Contents
  1. ComponentStore Example
  2. Common Mistakes

NgRx ComponentStore is a lightweight state management solution for component-scoped state — more structured than a simple service but without the global store overhead of full NgRx. It provides a typed state container with updaters (synchronous state changes), effects (async operations), and selectors (memoised derived state). Unlike global NgRx Store, ComponentStore instances are created and destroyed with the component, making them ideal for complex components with their own loading states, lists, and selected-item tracking.

ComponentStore Example

// npm install @ngrx/component-store
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { Injectable } from '@angular/core';

// ── State interface ────────────────────────────────────────────────────────
interface PostListState {
  posts:       PostSummaryDto[];
  total:       number;
  page:        number;
  loading:     boolean;
  error:       string | null;
  selectedId:  number | null;
}

// ── ComponentStore subclass ────────────────────────────────────────────────
@Injectable()   // provided at component level — not root
export class PostListStore extends ComponentStore<PostListState> {

  constructor(private api: PostsApiService) {
    super({    // initial state
      posts: [], total: 0, page: 1,
      loading: false, error: null, selectedId: null,
    });
  }

  // ── Selectors — memoised derived state ────────────────────────────────
  readonly posts$      = this.select(state => state.posts);
  readonly loading$    = this.select(state => state.loading);
  readonly error$      = this.select(state => state.error);
  readonly hasMore$    = this.select(
    state => state.posts.length < state.total
  );
  readonly selectedPost$ = this.select(state =>
    state.posts.find(p => p.id === state.selectedId) ?? null
  );

  // ── Updaters — synchronous state mutations ────────────────────────────
  readonly selectPost = this.updater((state, id: number) => ({
    ...state, selectedId: id,
  }));

  readonly clearSelection = this.updater(state => ({
    ...state, selectedId: null,
  }));

  readonly nextPage = this.updater(state => ({
    ...state, page: state.page + 1,
  }));

  // ── Effects — async operations (HTTP calls) ───────────────────────────
  readonly loadPosts = this.effect<{ page: number; size: number }>(
    params$ => params$.pipe(
      switchMap(({ page, size }) => {
        this.patchState({ loading: true, error: null });
        return this.api.getPublished(page, size).pipe(
          tapResponse(
            result => this.patchState({
              posts:   result.items,
              total:   result.total,
              loading: false,
            }),
            (err: HttpErrorResponse) => this.patchState({
              loading: false,
              error:   err.message,
            }),
          )
        );
      })
    )
  );

  readonly deletePost = this.effect<number>(
    id$ => id$.pipe(
      switchMap(id =>
        this.api.delete(id).pipe(
          tapResponse(
            () => this.patchState(state => ({
              posts: state.posts.filter(p => p.id !== id),
              total: state.total - 1,
            })),
            () => {/* handle error */},
          )
        )
      )
    )
  );
}

// ── Component using ComponentStore ────────────────────────────────────────
@Component({
  selector:   'app-post-list',
  standalone:  true,
  providers:  [PostListStore],   // provides store scoped to this component
  imports:    [AsyncPipe],
  template: `
    @if (store.loading$ | async) { <app-spinner /> }
    @for (post of store.posts$ | async; track post?.id) {
      <app-post-card [post]="post!" (click)="store.selectPost(post!.id)" />
    }
    @if (store.hasMore$ | async) {
      <button (click)="loadNext()">Load More</button>
    }
  `,
})
export class PostListComponent implements OnInit {
  protected store = inject(PostListStore);

  ngOnInit() {
    this.store.loadPosts({ page: 1, size: 10 });
  }

  loadNext() {
    this.store.nextPage();
    // In a real impl, effect would react to page$ change via withLatestFrom
  }
}
Note: tapResponse (imported from @ngrx/component-store) is ComponentStore’s equivalent of switchMap + tap + catchError. It takes two callbacks: success and error. Unlike a plain tap, errors in the success callback do not terminate the effect stream — they are caught internally. This makes effects resilient: a single HTTP error does not kill the entire effect, and subsequent calls to the effect work normally. Always use tapResponse in ComponentStore effects instead of manual catchError.
Tip: ComponentStore is the sweet spot between raw service+Signals and full NgRx for complex components. Use it when a component needs: its own loading state separate from other component instances, multiple async operations that need coordination, undo/redo-like operations, or debugging benefits from structured state. The key advantage over Signals: the state shape is declared as a typed interface, all mutations go through updater or effect, making state changes visible and traceable — useful for debugging complex component behaviour.
Warning: ComponentStore must be provided at the component level (providers: [PostListStore]), not at root (providedIn: 'root'). Providing it at root creates one global instance shared by all component instances — the entire point of ComponentStore (per-component state) is lost. Each component instance must get its own store instance, which the providers: [] array on the component decorator ensures.

Common Mistakes

Mistake 1 — Providing ComponentStore at root (all components share state)

❌ Wrong — @Injectable({ providedIn: 'root' }) on ComponentStore; all instances share one state.

✅ Correct — @Injectable() + providers: [PostListStore] on the component.

Mistake 2 — Not using tapResponse in effects (error terminates the effect)

❌ Wrong — plain tap() with manual error handling; uncaught error terminates the effect Observable.

✅ Correct — use tapResponse(successFn, errorFn) for resilient effects that survive errors.

🧠 Test Yourself

Two instances of PostListComponent are on the same page, each with providers: [PostListStore]. One loads page 1, the other page 2. Do they share the same loading state?