NgRx Store vs ComponentStore: A State-Scope Decision Guide
Decide whether to put Angular state in the global NgRx Store, a component-scoped ComponentStore, or signalStore based on sharing needs and update frequency.
31 Oct 2025, 01:28 UTC

The decision: where should this state live?
Before adding a new piece of state to an Angular application, decide its scope. State in the global NgRx Store is visible to every component and service, persists across navigation, and appears in Redux DevTools. State kept in a ComponentStore belongs to one component instance, disappears when that component is destroyed, and does not appear in DevTools by default. Putting the wrong state in the wrong place either bloats the global store with UI toggles or forces you to lift local state upward later.
Useful takeaway: reserve the global Store for state that multiple features genuinely share—authentication, user preferences, cached server data—and use ComponentStore for ephemeral UI state such as search filters, modal visibility, or per-component loading flags. If you are on Angular 17+ with @ngrx/signals, signalStore offers a middle ground with fine-grained reactivity at any injector level.
Constraints to check first
- Angular and NgRx versions: signalStore requires Angular 17+ and @ngrx/signals. Classic Store and ComponentStore work from NgRx v12+.
- Sharing requirement: Does more than one unrelated component need to read or write this state?
- Update frequency: Does the state change dozens of times per second, such as during a drag operation?
- Debugging needs: Do you need time-travel debugging in Redux DevTools?
Supported options compared
| Option | Scope | Best for | Boilerplate | DevTools | Memoization |
|---|---|---|---|---|---|
| NgRx Store | Global, single immutable tree | Shared, persistent state (auth, preferences) | High (actions, reducers, effects, selectors, feature registration) | Yes, time-travel | App-wide selector memoization |
| ComponentStore | Component instance | Local UI state (forms, modals, loading indicators) | Low (inline updaters, local effects) | No, unless manually integrated | Per-instance selector memoization |
| signalStore (v17+) | Any injector level | Fine-grained reactive state, migration middle ground | Medium (signal-based updaters and computed selectors) | Limited; classic DevTools integration varies | Signal-based, fine-grained |
Trade-offs by scenario
Global Store
The global Store gives you a single source of truth and unidirectional data flow. Actions, reducers, selectors, and effects are explicit, which helps large teams reason about data changes. The cost is ceremony: every feature needs actions, reducers, selectors, and module registration. Because every dispatched action runs through the global effect pipeline, heavy side effects such as websocket handlers can affect the whole application unless you scope them with operators like takeUntil.
ComponentStore
ComponentStore reduces ceremony with updaters and effects that live inside the component. Selectors memoize per instance, so a search filter in one component does not recompute for every other component. However, ComponentStore effects do not automatically cancel when the component is destroyed. Use takeUntilDestroyed() (Angular 16+) or the built-in effect cleanup to avoid memory leaks. State changes are not recorded in DevTools unless you wire them in manually.
signalStore (Angular 17+)
signalStore lets you provide state at any injector level, so you can scope it globally or locally without choosing a rigid boundary up front. It is not a drop-in replacement for classic Store or ComponentStore; migrating means rewriting reducers and effects as signal-based updaters and computed selectors.
Concrete implementation: local search state with ComponentStore
This example keeps search state inside one component so it does not pollute the global Store.
import { ComponentStore } from '@ngrx/component-store';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { tap, switchMap } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
export interface SearchState {
query: string;
loading: boolean;
results: unknown[];
}
export class SearchStore extends ComponentStore<SearchState> {
readonly query$ = this.select((state) => state.query);
readonly loading$ = this.select((state) => state.loading);
readonly setQuery = this.updater((state, query: string) => ({
...state,
query,
}));
readonly search = this.effect((trigger$) =
trigger$.pipe(
tap(() => this.patchState({ loading: true })),
switchMap((query: string) => this.searchService.search(query)),
tapResponse(
(results) => this.patchState({ loading: false, results }),
() => this.patchState({ loading: false })
),
takeUntilDestroyed()
)
);
constructor(private searchService: SearchService) {
super({ query: '', loading: false, results: [] });
}
}
Provide this store in the component that owns the search UI, not in the root module. Each component instance gets its own state. When the component is destroyed, takeUntilDestroyed() cancels the effect subscription.
Concrete implementation: global auth state with NgRx Store
Authentication state belongs in the global Store because the header, route guards, and API interceptors all need it.
import { createFeature, createReducer, on } from '@ngrx/store';
import { createAction, props } from '@ngrx/store';
export const login = createAction('[Auth] Login', props<{ credentials: { email: string; password: string } }>());
export const loginSuccess = createAction('[Auth] Login Success', props<{ user: { id: string; email: string } }>());
export interface AuthState {
user: { id: string; email: string } | null;
loading: boolean;
}
export const initialAuthState: AuthState = { user: null, loading: false };
export const authReducer = createReducer(
initialAuthState,
on(login, (state) => ({ ...state, loading: true })),
on(loginSuccess, (state, { user }) => ({ ...state, loading: false, user }))
);
export const authFeature = createFeature({
name: 'auth',
reducer: authReducer,
});
Register the feature in the application module or bootstrap config:
import { StoreModule } from '@ngrx/store';
import { authFeature } from './auth/auth.reducer';
// In AppModule imports:
StoreModule.forFeature(authFeature)
Selectors read from the auth slice anywhere in the application:
import { createFeatureSelector } from '@ngrx/store';
export const selectAuthState = createFeatureSelector<AuthState>('auth');
Validate the boundary
After adding state, verify that it lives where you intended.
Check DevTools isolation
- Open the Redux DevTools extension in your browser.
- Dispatch a local ComponentStore action, such as typing in the search box.
- Expected check: no new global action appears in DevTools.
- Dispatch a global Store action, such as login.
- Expected check: the action appears in DevTools with before-and-after state.
- Risk: if local ComponentStore updates appear in DevTools, you may have accidentally registered the store globally or integrated DevTools manually.
Run the NgRx lint rule
Run this from the project root. No special permissions are required.
npx eslint src --rule 'ngrx/no-global-store-for-local-state: error'
Replace src with your source directory placeholder. Expected check: the rule flags any global feature registration that holds transient UI state. Risk: the rule only catches patterns it is configured to detect; manual review is still required.
Unit test a ComponentStore effect
Use TestBed with a real ComponentStore instance and fakeAsync:
import { TestBed } from '@angular/core/testing';
import { fakeAsync, tick } from '@angular/core/testing';
import { SearchStore } from './search.store';
describe('SearchStore', () => {
it('updates loading state during search', fakeAsync(() => {
const store = TestBed.inject(SearchStore);
store.setQuery('ngrx');
expect(store.selectSnapshot((state) => state.query)).toBe('ngrx');
tick();
}));
});
Expected check: the snapshot returns the locally set value without involving the global Store. Risk: TestBed.inject requires the store to be provided in the testing module or component scope.
Limitations
- signalStore is not a drop-in replacement for classic Store or ComponentStore; migration requires rewriting state logic.
- ComponentStore state changes are invisible to DevTools unless you manually integrate them.
- Global Store effects run for every dispatched action, so unscoped side effects can create memory leaks or unnecessary work.
- Do not share a ComponentStore instance across unrelated components through providers unless you explicitly intend shared state.
Practical verification
Open Angular DevTools while the application runs. Dispatch rapid updates to a ComponentStore-backed search input and observe the component tree. Only the owning component and its descendants should mark for change detection. Repeat the same rapid updates against a global Store-backed counter and confirm whether unrelated components are affected. If they are, consider moving that state to ComponentStore or signalStore.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.