| |

Angular 84 🅰️ Component Store

⏱️ Estimated read: 18 min | |

The previous chapters covered the global store with actions, reducers, effects, and selectors, and then went deeper with effects and entity. The global store is designed for application-wide state — the kind that outlives any single component and is shared across features. But not all state belongs in the global store. A component that opens a dialog, loads a list, and closes when the user navigates away has state that lives only as long as the component. Putting that state in the global store means it persists after the component is gone, and every feature that uses the global store must contend with it.

@ngrx/component-store is the answer to this problem. It is a separate package from @ngrx/store, designed for component-scoped state. It provides a class-based store that extends ComponentStore<T>, exposes state as observables, provides updater methods for synchronous mutations, and effect methods for side effects. It is provided at the component level, so its lifetime is tied to the component’s lifetime. When the component is destroyed, the store is destroyed, and its state is gone .

Key point: ComponentStore is the local-state counterpart to the global store. It is lighter, has no actions, no reducers, and no global registration. The state is held in a single object, mutated through updater methods, and read through select observables. Effects are defined with effect and handle async operations. The component’s providers array determines the scope — provide it at the component that owns the state, and the state lives and dies with that component .


Why ComponentStore exists

The global store is powerful, but it is heavy. Every feature added to it requires actions, a reducer slice, selectors, and often effects. For a component that needs a local list, a loading flag, and a couple of derived values, this is disproportionate.

The global-state pollution problem. A feature that opens a modal and loads a list of users does not need that list to be globally accessible. It needs it while the modal is open. If the list is in the global store, it survives the modal’s closure. The next time the modal opens, the stale list is still there. The developer must remember to clear it. ComponentStore makes the state ephemeral by default — it is created when the component is created and destroyed when the component is destroyed .

The action/reducer ceremony problem. A simple toggle in the global store requires an action, a reducer case, and a selector. In ComponentStore, it requires one updater method. The ceremony is reduced to the minimum: a method that takes the current state and returns the new state. No action names, no switch statements, no selector memoization boilerplate .

The RxJS integration problem. ComponentStore is built on RxJS. select returns observables that can be composed with any RxJS operator. effect receives a trigger observable and returns an observable. For teams already using RxJS heavily, the API is natural. For teams using signals, the NgRx ecosystem provides @ngrx/signals as the successor, but ComponentStore remains widely used in existing codebases .

The testing problem. A ComponentStore is a class that can be instantiated directly in tests. Its updater methods are pure functions of state. Its select observables can be subscribed to synchronously. The effect methods can be triggered with a subject and the resulting state asserted. There is no TestBed requirement for the store itself .

The trade-off. ComponentStore is a legacy API in the sense that NgRx now recommends @ngrx/signals for new local state management. The NgRx documentation and community guides position ComponentStore as “the knowledge you need to read and maintain existing code” rather than the tool to choose for new features . For new projects, @ngrx/signals (SignalStore) is the recommended choice. For existing projects, ComponentStore remains fully supported and functional.


a. The ComponentStore Pattern

A ComponentStore is a class that extends ComponentStore<T> where T is the state interface. The constructor passes the initial state to super(). The class exposes three kinds of members: select for reading, updater for synchronous mutations, and effect for asynchronous operations.

import { ComponentStore } from '@ngrx/component-store';
import { Injectable, inject } from '@angular/core';
import { switchMap, tap } from 'rxjs';
import { tapResponse } from '@ngrx/operators';

export interface TasksState {
  tasks: Task[];
  filter: 'all' | 'active' | 'completed';
  loading: boolean;
}

@Injectable()
export class TasksStore extends ComponentStore<TasksState> {
  private taskService = inject(TaskService);

  constructor() {
    super({ tasks: [], filter: 'all', loading: false });
  }

  // Select: read state as observable
  readonly tasks$ = this.select((state) => state.tasks);
  readonly filter$ = this.select((state) => state.filter);
  readonly loading$ = this.select((state) => state.loading);

  // Derived selectors
  readonly filteredTasks$ = this.select(
    this.tasks$,
    this.filter$,
    (tasks, filter) => {
      switch (filter) {
        case 'active': return tasks.filter(t => !t.completed);
        case 'completed': return tasks.filter(t => t.completed);
        default: return tasks;
      }
    }
  );

  // Updater: synchronous mutation
  readonly setFilter = this.updater((state, filter: TasksState['filter']) => ({
    ...state,
    filter,
  }));

  readonly addTask = this.updater((state, task: Task) => ({
    ...state,
    tasks: [...state.tasks, task],
  }));

  // Effect: asynchronous operation
  readonly loadTasks = this.effect<void>((trigger$) =>
    trigger$.pipe(
      tap(() => this.patchState({ loading: true })),
      switchMap(() =>
        this.taskService.getAll().pipe(
          tapResponse({
            next: (tasks) => this.patchState({ tasks, loading: false }),
            error: () => this.patchState({ loading: false }),
          })
        )
      )
    )
  );
}

The select method takes a projector function and returns an observable. It is memoized — the projector only runs when the state changes. Multiple selects can be combined by passing them as arguments before the projector, as shown in filteredTasks$ .

The updater method takes a function (state, payload) => newState. The payload is the argument passed when the method is called. The return value replaces the state. The method itself is a function that can be called from the component.

The effect method takes a function that receives a trigger observable and returns an observable. The effect subscribes to the trigger, and when it emits, the effect runs. The tapResponse operator from @ngrx/operators handles the success and error cases without manually constructing the observable. Without it, the effect uses map and catchError explicitly .

The store is provided at the component that owns the state:

@Component({
  selector: 'tasks-page',
  providers: [TasksStore],
  template: `
    @if (store.loading$ | async) {
      <p>Loading...</p>
    }
    <ul>
      @for (task of store.filteredTasks$ | async; track task.id) {
        <li>{{ task.title }}</li>
      }
    </ul>
    <button (click)="store.setFilter('active')">Active</button>
    <button (click)="store.loadTasks()">Reload</button>
  `,
})
export class TasksPageComponent {
  store = inject(TasksStore);

  constructor() {
    this.store.loadTasks();
  }
}

The loadTasks() method is called in the constructor. Because it is an effect, it returns void. The trigger observable is a void observable that emits once when the method is called. The effect subscribes to it and runs the HTTP call. The component reads state through the select observables using the async pipe .


b. Effects and tapResponse

The effect method in ComponentStore is where side effects live. Unlike the global store’s effects, which are triggered by actions, ComponentStore effects are triggered by method calls. Each call to an effect method emits once on its trigger observable, and the effect runs.

The effect signature is:

readonly effectName = this.effect<PayloadType>((trigger$) => {
  return trigger$.pipe(
    // operators
  );
});

The trigger$ emits the payload each time the method is called. The PayloadType is the type of the argument passed to the method. For an effect with no payload, use this.effect<void>.

The tapResponse operator from @ngrx/operators is the recommended way to handle the success and error cases. It subscribes to the inner observable and calls next on success or error on failure, without terminating the effect’s stream:

import { tapResponse } from '@ngrx/operators';

readonly loadTasks = this.effect<void>((trigger$) =>
  trigger$.pipe(
    tap(() => this.patchState({ loading: true })),
    switchMap(() =>
      this.taskService.getAll().pipe(
        tapResponse({
          next: (tasks) => this.patchState({ tasks, loading: false }),
          error: (err) => this.patchState({ loading: false, error: err.message }),
          finalize: () => console.log('load complete'),
        })
      )
    )
  )
);

Without tapResponse, the effect uses map to transform the response into a patchState call and catchError to handle errors. tapResponse is cleaner because it separates the next, error, and finalize callbacks without nesting .

The patchState method is a shorthand for updater that merges a partial state into the current state. It is equivalent to calling this.updater((state) => ({ ...state, ...partial })) but without creating a named method. It is used inside effects and updaters to update multiple properties at once.

The setState method replaces the entire state. It is less common than patchState but useful when the new state is known completely.

The choice of flattening operator inside the effect is the same as for global effects. switchMap cancels the previous request when a new one arrives. concatMap queues requests. mergeMap runs them in parallel. exhaustMap ignores new requests while one is running .


c. ComponentStore with Entity

The @ngrx/entity package can be used with ComponentStore just as it is with the global store. The EntityAdapter provides the same CRUD methods and selectors, and the ComponentStore’s updater methods call them .

import { ComponentStore } from '@ngrx/component-store';
import { createEntityAdapter, EntityState } from '@ngrx/entity';

export interface PersonState extends EntityState<Person> {
  loading: boolean;
}

const personAdapter = createEntityAdapter<Person>();

const initialState = personAdapter.getInitialState({ loading: false });

@Injectable()
export class PersonStore extends ComponentStore<PersonState> {
  constructor(private personService: PersonService) {
    super(initialState);
  }

  // Selectors from the adapter
  readonly persons$ = this.select((state) => personAdapter.getSelectors().selectAll(state));
  readonly total$ = this.select((state) => personAdapter.getSelectors().selectTotal(state));

  // Updaters using adapter methods
  readonly addPerson = this.updater((state, person: Person) =>
    personAdapter.addOne(person, state)
  );

  readonly updatePerson = this.updater((state, update: Update<Person>) =>
    personAdapter.updateOne(update, state)
  );

  readonly removePerson = this.updater((state, id: string) =>
    personAdapter.removeOne(id, state)
  );

  // Effect
  readonly loadPersons = this.effect<void>((trigger$) =>
    trigger$.pipe(
      tap(() => this.patchState({ loading: true })),
      switchMap(() =>
        this.personService.getAll().pipe(
          tapResponse({
            next: (persons) => this.patchState(
              personAdapter.setAll(persons, { loading: false })
            ),
            error: () => this.patchState({ loading: false }),
          })
        )
      )
    )
  );
}

The select for the entity collection uses the adapter’s selectAll method. The updater methods call the adapter’s CRUD methods. The effect’s patchState merges the adapter result with the loading flag. The pattern is the same as the global store’s reducer, but the adapter calls happen inside updaters instead of reducer cases .

This combination is useful when a component-scoped store needs to manage a collection. The adapter provides the normalized state shape and the standard CRUD operations. The ComponentStore provides the scoping and the effect mechanism.


Complete Example Session

This session builds a component-scoped task list with a filter, an entity adapter, and an effect for loading.

// ============================================
// PART 1: THE MODEL
// ============================================

// task.model.ts
export interface Task {
  id: string;
  title: string;
  completed: boolean;
}

// ============================================
// PART 2: THE COMPONENT STORE
// ============================================

// tasks.store.ts
import { ComponentStore } from '@ngrx/component-store';
import { createEntityAdapter, EntityState, Update } from '@ngrx/entity';
import { Injectable, inject } from '@angular/core';
import { switchMap, tap } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
import { TaskService } from './task.service';
import { Task } from './task.model';

export type TaskFilter = 'all' | 'active' | 'completed';

export interface TasksState extends EntityState<Task> {
  filter: TaskFilter;
  loading: boolean;
  error: string | null;
}

const taskAdapter = createEntityAdapter<Task>({
  selectId: (task) => task.id,
  sortComparer: (a, b) => a.title.localeCompare(b.title),
});

const initialState: TasksState = taskAdapter.getInitialState({
  filter: 'all',
  loading: false,
  error: null,
});

@Injectable()
export class TasksStore extends ComponentStore<TasksState> {
  private taskService = inject(TaskService);

  constructor() {
    super(initialState);
  }

  // Selectors
  readonly tasks$ = this.select((state) =>
    taskAdapter.getSelectors().selectAll(state)
  );

  readonly filter$ = this.select((state) => state.filter);

  readonly loading$ = this.select((state) => state.loading);

  readonly filteredTasks$ = this.select(
    this.tasks$,
    this.filter$,
    (tasks, filter) => {
      switch (filter) {
        case 'active': return tasks.filter(t => !t.completed);
        case 'completed': return tasks.filter(t => t.completed);
        default: return tasks;
      }
    }
  );

  readonly total$ = this.select((state) =>
    taskAdapter.getSelectors().selectTotal(state)
  );

  // Updaters
  readonly setFilter = this.updater((state, filter: TaskFilter) => ({
    ...state,
    filter,
  }));

  readonly addTask = this.updater((state, task: Task) =>
    taskAdapter.addOne(task, state)
  );

  readonly toggleTask = this.updater((state, id: string) => {
    const task = state.entities[id];
    if (!task) return state;
    return taskAdapter.updateOne(
      { id, changes: { completed: !task.completed } },
      state
    );
  });

  readonly removeTask = this.updater((state, id: string) =>
    taskAdapter.removeOne(id, state)
  );

  readonly clearCompleted = this.updater((state) => {
    const completedIds = Object.values(state.entities)
      .filter((t): t is Task => t !== undefined && t.completed)
      .map(t => t.id);
    return taskAdapter.removeMany(completedIds, state);
  });

  // Effects
  readonly loadTasks = this.effect<void>((trigger$) =>
    trigger$.pipe(
      tap(() => this.patchState({ loading: true, error: null })),
      switchMap(() =>
        this.taskService.getAll().pipe(
          tapResponse({
            next: (tasks) => this.patchState(
              taskAdapter.setAll(tasks, { loading: false })
            ),
            error: (err: Error) => this.patchState({
              loading: false,
              error: err.message,
            }),
          })
        )
      )
    )
  );

  readonly addTaskRemote = this.effect<Omit<Task, 'id'>>((trigger$) =>
    trigger$.pipe(
      switchMap((task) =>
        this.taskService.create(task).pipe(
          tapResponse({
            next: (created) => this.addTask(created),
            error: (err: Error) => this.patchState({ error: err.message }),
          })
        )
      )
    )
  );
}
// ============================================
// PART 3: THE SERVICE
// ============================================

// task.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Task } from './task.model';

@Injectable({ providedIn: 'root' })
export class TaskService {
  private http = inject(HttpClient);

  getAll(): Observable<Task[]> {
    return this.http.get<Task[]>('/api/tasks');
  }

  create(task: Omit<Task, 'id'>): Observable<Task> {
    return this.http.post<Task>('/api/tasks', task);
  }
}
// ============================================
// PART 4: THE COMPONENT
// ============================================

// tasks-page.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { TasksStore } from './tasks.store';

@Component({
  selector: 'tasks-page',
  imports: [AsyncPipe],
  providers: [TasksStore],
  template: `
    @if (store.loading$ | async) {
      <p class="loading">Loading tasks...</p>
    }

    @if (store.error$ | async; as error) {
      <p class="error">{{ error }}</p>
    }

    <p>Total: {{ store.total$ | async }}</p>

    <div class="filters">
      <button (click)="store.setFilter('all')">All</button>
      <button (click)="store.setFilter('active')">Active</button>
      <button (click)="store.setFilter('completed')">Completed</button>
    </div>

    <ul>
      @for (task of store.filteredTasks$ | async; track task.id) {
        <li>
          <input
            type="checkbox"
            [checked]="task.completed"
            (change)="store.toggleTask(task.id)"
          />
          <span [class.completed]="task.completed">{{ task.title }}</span>
          <button (click)="store.removeTask(task.id)">×</button>
        </li>
      }
    </ul>

    <button (click)="store.clearCompleted()">Clear completed</button>
    <button (click)="store.loadTasks()">Reload</button>
  `,
})
export class TasksPageComponent implements OnInit {
  store = inject(TasksStore);

  ngOnInit() {
    this.store.loadTasks();
  }
}
// ============================================
// PART 5: THE TEST
// ============================================

// tasks.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { TasksStore } from './tasks.store';
import { TaskService } from './task.service';
import { Task } from './task.model';

describe('TasksStore', () => {
  let store: TasksStore;
  let taskService: jasmine.SpyObj<TaskService>;

  const mockTasks: Task[] = [
    { id: '1', title: 'Write tests', completed: false },
    { id: '2', title: 'Read docs', completed: true },
  ];

  beforeEach(() => {
    const spy = jasmine.createSpyObj('TaskService', ['getAll', 'create']);

    TestBed.configureTestingModule({
      providers: [
        TasksStore,
        { provide: TaskService, useValue: spy },
      ],
    });

    store = TestBed.inject(TasksStore);
    taskService = TestBed.inject(TaskService) as jasmine.SpyObj<TaskService>;
  });

  it('loads tasks into the entity state', () => {
    taskService.getAll.and.returnValue(of(mockTasks));

    store.loadTasks();

    expect(store.tasks$).toBeDefined();
  });

  it('toggles a task', () => {
    store.addTask(mockTasks[0]);
    store.toggleTask('1');

    let toggled: Task | undefined;
    store.tasks$.subscribe(tasks => {
      toggled = tasks.find(t => t.id === '1');
    });

    expect(toggled?.completed).toBe(true);
  });

  it('filters active tasks', () => {
    store.addTask(mockTasks[0]);
    store.addTask(mockTasks[1]);
    store.setFilter('active');

    let filtered: Task[] = [];
    store.filteredTasks$.subscribe(tasks => {
      filtered = tasks;
    });

    expect(filtered.length).toBe(1);
    expect(filtered[0].completed).toBe(false);
  });
});

The five parts cover the model, the component store with entity adapter, the service, the consuming component, and the test.


Quick Reference

The ComponentStore API

MemberPurposeReturns
select(projector)Read stateObservable<T>
updater(fn)Define synchronous mutation(payload) => void
effect(fn)Define async side effect(payload) => void
patchState(partial)Merge partial statevoid
setState(state)Replace entire statevoid

The Select Composition

PatternExample
Singlethis.select(s => s.items)
Combinedthis.select(this.items$, this.filter$, (items, filter) => ...)
Derivedthis.select(this.items$, items => items.length)

The Effect Operators

OperatorPurpose
tapSide effect before async
switchMapCancel previous request
concatMapQueue requests
mergeMapParallel requests
exhaustMapIgnore while running
tapResponseHandle next/error/finalize

The Scope Comparison

FeatureGlobal StoreComponentStore
ScopeApplicationComponent
RegistrationprovideStoreComponent providers
State lifetimeApplicationComponent
MutationReducer + Actionupdater
Side effectscreateEffecteffect
Readselectselect
Test setupprovideMockStoreDirect instantiation

Best Practices

✅ Do This:

// Provide the store at the component that owns the state
@Component({ providers: [TasksStore] })                        // ✅
// Use tapResponse for effect success and error
tapResponse({ next: ..., error: ... })                         // ✅
// Use switchMap for loads that should cancel previous
switchMap(() => this.service.getAll())                         // ✅
// Use the entity adapter for collections
taskAdapter.addOne(task, state)                                // ✅
// Derive state with combined selects
this.select(this.items$, this.filter$, (items, filter) => ...) // ✅

❌ Don’t Do This:

// Don't provide ComponentStore at root
@Injectable({ providedIn: 'root' })                            // ❌
// Don't mutate state directly
state.tasks.push(task);  // use updater                         // ❌
// Don't use mergeMap for loads that should cancel
mergeMap(() => this.service.getAll())                          // ❌
// Don't forget to call the effect
// store.loadTasks() is required; declaring it does nothing.    // ❌

Common Pitfalls

PitfallWhy It HappensFix
State persists after component destroyedStore provided at rootMove to component providers
Effect never runsEffect method not calledCall store.loadTasks()
Select doesn’t updateSubscribed outside the storeUse select observables
Memory leakManual subscription in componentUse async pipe or takeUntilDestroyed
Entity not sortedNo sortComparerAdd to adapter config
Effect stream breaksMissing catchError or tapResponseUse tapResponse

Real-World Examples

1. Basic ComponentStore

@Injectable()
export class CounterStore extends ComponentStore<{ count: number }> {
  constructor() { super({ count: 0 }); }
}

2. Select

readonly count$ = this.select(state => state.count);

3. Updater

readonly increment = this.updater(state => ({ count: state.count + 1 }));

4. Effect

readonly load = this.effect<void>(trigger$ => trigger$.pipe(switchMap(...)));

5. Patch State

this.patchState({ loading: false, error: null });

6. Combined Select

this.select(this.items$, this.filter$, (items, filter) => ...)

7. Entity Adapter in Updater

readonly add = this.updater((state, item) => adapter.addOne(item, state));

8. tapResponse

tapResponse({ next: (data) => ..., error: (err) => ... })

9. Component Provider

@Component({ providers: [TasksStore] })

10. Test Instantiation

store = TestBed.inject(TasksStore);

Visual

The ComponentStore Architecture

┌──────────────────────────────────────────────┐
│  COMPONENTSTORE ARCHITECTURE                 │
│                                              │
│  Component                                   │
│  providers: [TasksStore]                     │
│       │                                      │
│       ▼                                      │
│  TasksStore extends ComponentStore<TasksState>│
│       │                                      │
│       ├─ select()    → Observable<T>         │
│       ├─ updater()   → sync mutation         │
│       ├─ effect()    → async side effect     │
│       └─ patchState() → partial update       │
│                                              │
│  State lives and dies with the component.    │
│  No actions, no reducers, no global store.   │
│                                              │
└──────────────────────────────────────────────┘

The ComponentStore vs Global Store

┌──────────────────────────────────────────────┐
│  GLOBAL STORE                                │
│                                              │
│  Component ──dispatch──> Action              │
│                │                             │
│                ▼                             │
│            Reducer ──> New State             │
│                │                             │
│                ▼                             │
│            Selector ──> Component            │
│                                              │
│  Application-scoped. Persists.               │
│                                              │
├──────────────────────────────────────────────┤
│  COMPONENTSTORE                              │
│                                              │
│  Component ──call──> updater()               │
│                │                             │
│                ▼                             │
│            New State (in store)              │
│                │                             │
│                ▼                             │
│            select() ──> Component            │
│                                              │
│  Component-scoped. Ephemeral.                │
│                                              │
└──────────────────────────────────────────────┘

The Effect Flow

┌──────────────────────────────────────────────┐
│  COMPONENTSTORE EFFECT                       │
│                                              │
│  store.loadTasks()                           │
│       │                                      │
│       ▼                                      │
│  trigger$ emits void                         │
│       │                                      │
│       ▼                                      │
│  tap(() => patchState({ loading: true }))    │
│       │                                      │
│       ▼                                      │
│  switchMap(() => service.getAll())           │
│       │                                      │
│       ▼                                      │
│  tapResponse({ next, error })                │
│       │                                      │
│       ▼                                      │
│  patchState({ tasks, loading: false })       │
│                                              │
│  The effect returns void.                    │
│  State changes trigger select re-emissions.  │
│                                              │
└──────────────────────────────────────────────┘

The ComponentStore + Entity Flow

┌──────────────────────────────────────────────┐
│  COMPONENTSTORE + ENTITY                     │
│                                              │
│  TasksState extends EntityState<Task>        │
│    { ids: [], entities: {}, loading: false } │
│                                              │
│  Updater uses adapter:                       │
│    addTask = this.updater((state, task) =>   │
│      taskAdapter.addOne(task, state)         │
│    );                                        │
│                                              │
│  Select uses adapter:                        │
│    tasks$ = this.select(state =>             │
│      taskAdapter.getSelectors().selectAll(state) │
│    );                                        │
│                                              │
│  Effect patches with adapter result:         │
│    this.patchState(                          │
│      taskAdapter.setAll(tasks, { loading: false }) │
│    );                                        │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Package@ngrx/component-store
ClassComponentStore<T>
ScopeComponent
RegistrationComponent providers
Readselect(projector)
Mutationupdater(fn)
Side effectseffect(fn)
Partial updatepatchState(partial)
Full replacesetState(state)
Entity integrationEntityAdapter in updaters and selects
Effect responsetapResponse from @ngrx/operators
TestingTestBed.inject(Store)

Key takeaways:

  • ComponentStore is the component-scoped state solution in the NgRx ecosystem. It is a separate package from @ngrx/store and is designed for state that lives only as long as the component that owns it. Providing it in a component’s providers array ties the state’s lifetime to the component’s lifetime .
  • The API has three parts: select, updater, and effect. select reads state as observables. updater defines synchronous mutations that take the current state and return the new state. effect defines asynchronous operations triggered by method calls .
  • There are no actions or reducers. The ceremony of the global store is absent. A mutation is a method that returns a new state. A side effect is a method that returns an observable. The component calls the methods directly.
  • tapResponse is the recommended way to handle effect results. It separates the next, error, and finalize callbacks without nesting, and it keeps the effect’s stream alive after an error .
  • The entity adapter works with ComponentStore. The adapter’s CRUD methods are called inside updater methods, and the adapter’s selectors are used inside select projectors. The state extends EntityState<T> and the initial state is generated by getInitialState .
  • ComponentStore is now considered a legacy pattern. NgRx recommends @ngrx/signals (SignalStore) for new local state management. ComponentStore remains fully supported and is widely used in existing codebases. The knowledge is valuable for reading and maintaining existing code .
  • Testing is direct. The store is a class that can be instantiated and injected through TestBed. The updater methods are pure functions. The select observables can be subscribed to synchronously. There is no global store setup required .

Remember: ComponentStore is the middle ground between a signal service and the global store. It is more structured than a service with signals — it provides the select/updater/effect pattern and the entity adapter integration. It is lighter than the global store — no actions, no reducers, no global registration, and the state is scoped to the component. Use it when the state belongs to a component tree and should be discarded when that tree is destroyed. For new projects, consider @ngrx/signals as the modern alternative, but understand ComponentStore for the existing code you will encounter.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!