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
}
}
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.updater or effect, making state changes visible and traceable — useful for debugging complex component behaviour.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.