Перейти к содержимому

43. NgRx: Component Store

ComponentStore — это облегчённая версия NgRx для управления локальным состоянием компонента. Он живёт и умирает вместе с компонентом, не загрязняет глобальный Store и идеален для сложных UI-компонентов, которые не нужно видеть снаружи 🎯


Окно терминала
npm install @ngrx/component-store

СитуацияРешение
Состояние только внутри компонентаComponentStore
Данные нужны в нескольких страницахNgRx Store
Нужна история изменений (time-travel)NgRx Store
Таблица с пагинацией/фильтром/сортировкойComponentStore
Локальный wizard/форма из нескольких шаговComponentStore
Загрузка данных для конкретного компонентаComponentStore

components/data-table/data-table.store.ts
import { Injectable } from '@angular/core';
import { ComponentStore, tapResponse } from '@ngrx/component-store';
import { Observable, EMPTY } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { UserService } from '../../services/user.service';
import { User } from '../../models/user.model';
export interface DataTableState {
users: User[];
loading: boolean;
error: string | null;
filter: string;
sortField: keyof User | null;
sortDirection: 'asc' | 'desc';
page: number;
pageSize: number;
selectedIds: Set<string>;
}
@Injectable()
export class DataTableStore extends ComponentStore<DataTableState> {
constructor(private userService: UserService) {
// Вызываем super с initialState
super({
users: [],
loading: false,
error: null,
filter: '',
sortField: 'name',
sortDirection: 'asc',
page: 1,
pageSize: 10,
selectedIds: new Set(),
});
}
// ===== SELECTORS =====
// select() — аналог createSelector
readonly users$ = this.select(state => state.users);
readonly loading$ = this.select(state => state.loading);
readonly error$ = this.select(state => state.error);
readonly filter$ = this.select(state => state.filter);
readonly sortField$ = this.select(state => state.sortField);
readonly selectedIds$ = this.select(state => state.selectedIds);
// Составной селектор (мемоизирован)
readonly filteredAndSortedUsers$ = this.select(
this.users$,
this.filter$,
this.sortField$,
this.select(state => state.sortDirection),
(users, filter, sortField, sortDirection) => {
let result = filter
? users.filter(u =>
u.name.toLowerCase().includes(filter.toLowerCase()) ||
u.email.toLowerCase().includes(filter.toLowerCase())
)
: users;
if (sortField) {
result = [...result].sort((a, b) => {
const aVal = String(a[sortField]);
const bVal = String(b[sortField]);
const cmp = aVal.localeCompare(bVal);
return sortDirection === 'asc' ? cmp : -cmp;
});
}
return result;
}
);
readonly paginatedUsers$ = this.select(
this.filteredAndSortedUsers$,
this.select(state => state.page),
this.select(state => state.pageSize),
(users, page, pageSize) => {
const start = (page - 1) * pageSize;
return {
items: users.slice(start, start + pageSize),
total: users.length,
pages: Math.ceil(users.length / pageSize),
};
}
);
readonly stats$ = this.select(
this.users$,
this.selectedIds$,
(users, selectedIds) => ({
total: users.length,
selected: selectedIds.size,
active: users.filter(u => u.isActive).length,
})
);
// ===== UPDATERS =====
// updater() — аналог on() в reducer, синхронное изменение состояния
readonly setFilter = this.updater((state, filter: string) => ({
...state,
filter,
page: 1, // Сбрасываем страницу при изменении фильтра
}));
readonly setPage = this.updater((state, page: number) => ({
...state, page
}));
readonly setSort = this.updater(
(state, { field, direction }: { field: keyof User; direction: 'asc' | 'desc' }) => ({
...state,
sortField: field,
sortDirection: direction,
})
);
readonly toggleSelect = this.updater((state, userId: string) => {
const selectedIds = new Set(state.selectedIds);
if (selectedIds.has(userId)) {
selectedIds.delete(userId);
} else {
selectedIds.add(userId);
}
return { ...state, selectedIds };
});
readonly selectAll = this.updater(state => ({
...state,
selectedIds: new Set(state.users.map(u => u.id)),
}));
readonly clearSelection = this.updater(state => ({
...state,
selectedIds: new Set<string>(),
}));
// ===== EFFECTS =====
// effect() — для асинхронной логики
readonly loadUsers = this.effect<void>(trigger$ =>
trigger$.pipe(
tap(() => this.patchState({ loading: true, error: null })),
switchMap(() =>
this.userService.getAll().pipe(
tapResponse(
users => this.patchState({ users, loading: false }),
error => this.patchState({
error: (error as Error).message,
loading: false
})
)
)
)
)
);
readonly deleteUser = this.effect<string>(userId$ =>
userId$.pipe(
switchMap(userId =>
this.userService.delete(userId).pipe(
tapResponse(
() => this.patchState(state => ({
users: state.users.filter(u => u.id !== userId),
})),
error => this.patchState({ error: (error as Error).message })
)
)
)
)
);
}

components/data-table/data-table.component.ts
@Component({
standalone: true,
imports: [AsyncPipe, ...],
providers: [DataTableStore], // Scope: только этот компонент и его потомки
template: `
<!-- Stats -->
@if (stats$ | async; as stats) {
<div class="toolbar">
Выбрано: {{ stats.selected }} из {{ stats.total }}
@if (stats.selected > 0) {
<button (click)="deleteSelected()">Удалить выбранные</button>
}
</div>
}
<!-- Filter -->
<input
placeholder="Поиск..."
(input)="onFilter($event)"
/>
<!-- Sort buttons -->
<button (click)="onSort('name')">
Имя {{ getSortIcon('name') }}
</button>
<!-- Table -->
@if (paginated$ | async; as data) {
@for (user of data.items; track user.id) {
<div class="row" [class.selected]="isSelected(user.id)">
<input
type="checkbox"
[checked]="isSelected(user.id)"
(change)="toggleSelect(user.id)"
/>
{{ user.name }} — {{ user.email }}
</div>
}
<!-- Pagination -->
<app-paginator
[total]="data.total"
[pages]="data.pages"
(pageChange)="store.setPage($event)"
/>
}
@if (loading$ | async) { <mat-progress-bar /> }
@if (error$ | async; as error) { <app-error [message]="error" /> }
`
})
export class DataTableComponent implements OnInit {
store = inject(DataTableStore);
stats$ = this.store.stats$;
paginated$ = this.store.paginatedUsers$;
loading$ = this.store.loading$;
error$ = this.store.error$;
sortField$ = this.store.sortField$;
ngOnInit() {
this.store.loadUsers(); // Вызываем effect
}
onFilter(event: Event) {
const filter = (event.target as HTMLInputElement).value;
this.store.setFilter(filter); // Вызываем updater
}
onSort(field: keyof User) {
// Логика сортировки
this.store.setSort({ field, direction: 'asc' });
}
toggleSelect(id: string) {
this.store.toggleSelect(id); // Вызываем updater
}
isSelected(id: string): boolean {
// Подписываемся через toSignal или async pipe
return false; // simplified
}
deleteSelected() {
// Получаем текущее состояние через get()
const selectedIds = this.store.get(state => state.selectedIds);
selectedIds.forEach(id => this.store.deleteUser(id));
this.store.clearSelection();
}
}

@Injectable()
export class DataTableStore extends ComponentStore<DataTableState> {
constructor(private service: UserService) {
super(initialState);
// onStoreInit — вызывается при инициализации Store
// Аналог ngOnInit
this.onStoreInit();
// tapResponse: специальный оператор NgRx для обработки ответа
// Гарантирует, что ошибки не завершат Observable
}
private onStoreInit = this.effect<void>(
trigger$ => trigger$.pipe(
tap(() => console.log('Store initialized'))
)
);
// ngOnDestroy вызывается автоматически вместе с компонентом
// Все subscriptions очищаются автоматически
}

// patchState — частичное обновление (merge)
this.patchState({ loading: true });
this.patchState(state => ({ page: state.page + 1 }));
// setState — полная замена состояния
this.setState({ ...this.get(), loading: true });
this.setState(state => ({ ...state, loading: true }));
// get() — синхронное чтение текущего состояния
const currentFilter = this.get(state => state.filter);