Use NgRx ComponentStore for component-scoped state in Angular
Keep feature-level UI state local with NgRx ComponentStore instead of bloating the global Store with actions only one component cares about.
02 May 2026, 06:28 UTC

Stop modeling every UI toggle as a global action
Your Angular feature has a task list. Users can filter by status, toggle a compact view, and open a detail drawer. The team puts all of this in the global NgRx Store: actions like FilterTasks, ToggleCompactView, and OpenDrawer, plus reducers and selectors for each. The state only ever matters inside one component, yet it lives in the application-wide store for the lifetime of the app.
This works, but it adds boilerplate and makes the global store harder to navigate. NgRx offers a lighter alternative for exactly this situation: ComponentStore, an observable-based store scoped to a single component or feature. An observable is a stream that emits new values when state changes.
What ComponentStore replaces
ComponentStore is an injectable class that holds local state and exposes it as observables. Instead of writing global actions and reducers, you define:
- Initial state: the default shape and values.
- Selectors: functions that derive pieces of state, exposed with select.
- Updaters: functions that change state using setState or patchState. patchState merges partial changes instead of replacing the whole object.
- Effects: optional side effects, such as HTTP calls, triggered by state changes.
When you provide the store in a component's providers array, the instance is destroyed when the component is destroyed. State does not survive navigation away from the feature.
Worked example: a task-filter store
Assumptions: Angular v14 or later, NgRx v12 or later, and @ngrx/component-store installed.
Run this in your Angular workspace root to generate a service:
ng generate service features/tasks/filter-store
No special permissions are required beyond write access to the project. Replace features/tasks/filter-store with your actual feature path.
Open the generated service and subclass ComponentStore:
import { ComponentStore } from '@ngrx/component-store';
import { Injectable } from '@angular/core';
export interface TaskFilterState {
status: 'all' | 'active' | 'completed';
compact: boolean;
drawerOpen: boolean;
}
@Injectable()
export class TaskFilterStore extends ComponentStore<TaskFilterState> {
readonly status$ = this.select((state) => state.status);
readonly compact$ = this.select((state) => state.compact);
readonly drawerOpen$ = this.select((state) => state.drawerOpen);
constructor() {
super({ status: 'all', compact: false, drawerOpen: false });
}
readonly setStatus = this.updater((state, status: TaskFilterState['status']) => {
return { ...state, status };
});
readonly toggleCompact = this.updater((state) => {
return { ...state, compact: !state.compact };
});
readonly openDrawer = this.updater((state) => {
return { ...state, drawerOpen: true };
});
readonly closeDrawer = this.updater((state) => {
return { ...state, drawerOpen: false };
});
}
Provide it in the component that owns the UI, not in the root module:
@Component({
selector: 'app-task-list',
templateUrl: './task-list.component.html',
providers: [TaskFilterStore]
})
export class TaskListComponent {
readonly status$ = this.store.status$;
readonly compact$ = this.store.compact$;
readonly drawerOpen$ = this.store.drawerOpen$;
constructor(readonly store: TaskFilterStore) {}
}
Bind to the observables in the template with the async pipe, which subscribes and unsubscribes automatically:
<select [value]="status$ | async" (change)="store.setStatus($any($event.target).value)">
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
<button (click)="store.toggleCompact()">{{ compact$ | async ? 'Comfortable' : 'Compact' }}</button>
<button (click)="store.openDrawer()">Open drawer</button>
Expected check: navigate away from app-task-list and back. Because the store lives in the component's providers, it resets to { status: 'all', compact: false, drawerOpen: false } instead of keeping the previous filter.
Risk: this local store does not appear in NgRx DevTools by default. If you need time-travel debugging for this state, you must bridge it manually or keep it in the global Store.
Trade-offs and when to stay global
| Situation | Recommended store |
|---|---|
| UI state for one component or route | ComponentStore |
| State shared by multiple unrelated features | Global NgRx Store |
| DevTools time-travel debugging required | Global NgRx Store |
| State must persist across route navigation | Global NgRx Store |
A common pitfall is reaching for selectSnapshot to read state synchronously. It returns the current value without an observable, which is useful in event handlers but easy to overlook if your team is used to global Store selectors. Another pitfall is duplicating the same ComponentStore in several components; if multiple screens need the same filter, lift it to a shared service or the global Store instead of prop-drilling a local store instance.
Diagnostic: should this state be local?
- Does only one component or feature route consume this state?
- Can the state reset when the user navigates away?
- Do you need DevTools time-travel for this specific state?
- If you answered yes to the first two and no to the third, ComponentStore is a good fit.
Bottom line
Use the global NgRx Store when state crosses feature boundaries or needs debugging tools. Use ComponentStore when state belongs to one component and should disappear with it. The result is less boilerplate, clearer ownership, and less risk of stale state lingering after navigation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.