43. NgRx: Component Store
🧩 NgRx ComponentStore — Локальное состояние
Заголовок раздела «🧩 NgRx ComponentStore — Локальное состояние»ComponentStore — это облегчённая версия NgRx для управления локальным состоянием компонента. Он живёт и умирает вместе с компонентом, не загрязняет глобальный Store и идеален для сложных UI-компонентов, которые не нужно видеть снаружи 🎯
Установка
Заголовок раздела «Установка»npm install @ngrx/component-storeКогда использовать ComponentStore?
Заголовок раздела «Когда использовать ComponentStore?»| Ситуация | Решение |
|---|---|
| Состояние только внутри компонента | ComponentStore |
| Данные нужны в нескольких страницах | NgRx Store |
| Нужна история изменений (time-travel) | NgRx Store |
| Таблица с пагинацией/фильтром/сортировкой | ComponentStore |
| Локальный wizard/форма из нескольких шагов | ComponentStore |
| Загрузка данных для конкретного компонента | ComponentStore |
Создание ComponentStore
Заголовок раздела «Создание ComponentStore»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 }) ) ) ) ) );}Использование ComponentStore в компоненте
Заголовок раздела «Использование ComponentStore в компоненте»@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(); }}Lifecycle hooks
Заголовок раздела «Lifecycle hooks»@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 vs setState
Заголовок раздела «patchState vs setState»// 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);