42. NgRx: Entity
🗂️ NgRx Entity — Управление коллекциями
Заголовок раздела «🗂️ NgRx Entity — Управление коллекциями»NgRx Entity — это надстройка над Store для работы с нормализованными коллекциями объектов. Вместо того чтобы писать одни и те же CRUD-редьюсеры снова и снова, Entity даёт тебе готовые операции за несколько строк кода 📦
Установка
Заголовок раздела «Установка»npm install @ngrx/entityEntityState — структура нормализованной коллекции
Заголовок раздела «EntityState — структура нормализованной коллекции»NgRx Entity хранит данные в нормализованном виде — не как массив [{id:1,...}, {id:2,...}], а как:
interface EntityState<T> { ids: string[] | number[]; // ["1", "2", "3"] — порядок entities: { [id: string]: T }; // { "1": {...}, "2": {...} } — быстрый доступ по ID}Почему это лучше массива:
- O(1) поиск по ID вместо O(n)
- Нет дублей (каждый ID уникален)
- Простые обновления без перебора массива
createEntityAdapter()
Заголовок раздела «createEntityAdapter()»import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';import { User } from '../../models/user.model';
// Создаём адаптер для типа Userexport const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({ // selectId: как получить уникальный ID (по умолчанию entity.id) selectId: (user: User) => user.id,
// sortComparer: функция сортировки (по умолчанию нет сортировки) sortComparer: (a: User, b: User) => a.name.localeCompare(b.name),});
// Расширяем EntityState дополнительными полямиexport interface UsersState extends EntityState<User> { loading: boolean; creating: boolean; error: string | null; selectedUserId: string | null; total: number; filter: string;}
// Начальное состояние через getInitialState()export const initialState: UsersState = usersAdapter.getInitialState({ loading: false, creating: false, error: null, selectedUserId: null, total: 0, filter: '',});CRUD операции адаптера
Заголовок раздела «CRUD операции адаптера»import { createReducer, on } from '@ngrx/store';import * as UsersActions from './users.actions';
export const usersReducer = createReducer( initialState,
// ===== LOAD (массив) ===== on(UsersActions.loadUsers, state => ({ ...state, loading: true, error: null })),
on(UsersActions.loadUsersSuccess, (state, { users, total }) => // setAll: заменяет ВСЕ entities новым массивом usersAdapter.setAll(users, { ...state, loading: false, total }) ),
// ===== CREATE ===== on(UsersActions.createUserSuccess, (state, { user }) => // addOne: добавляет одного, НЕ заменяет если уже есть usersAdapter.addOne(user, { ...state, creating: false, total: state.total + 1 }) ),
// addMany: добавляет несколько on(UsersActions.importUsersSuccess, (state, { users }) => usersAdapter.addMany(users, state) ),
// ===== UPDATE ===== on(UsersActions.updateUserSuccess, (state, { id, changes }) => // updateOne: частичное обновление usersAdapter.updateOne({ id, changes }, state) ),
on(UsersActions.updateManyUsers, (state, { updates }) => // updateMany: несколько обновлений usersAdapter.updateMany(updates, state) ),
// setOne: полная замена (upsert с заменой) on(UsersActions.setUserFromSocket, (state, { user }) => usersAdapter.setOne(user, state) ),
// upsertOne: добавить если нет, обновить если есть on(UsersActions.syncUser, (state, { user }) => usersAdapter.upsertOne(user, state) ),
// upsertMany on(UsersActions.syncUsers, (state, { users }) => usersAdapter.upsertMany(users, state) ),
// ===== DELETE ===== on(UsersActions.deleteUserSuccess, (state, { id }) => // removeOne: удалить по ID usersAdapter.removeOne(id, { ...state, total: state.total - 1 }) ),
on(UsersActions.deleteUsersSuccess, (state, { ids }) => // removeMany: удалить несколько по IDs usersAdapter.removeMany(ids, state) ),
// removeWhere: удалить по предикату on(UsersActions.deleteInactiveUsers, state => usersAdapter.removeMany( user => !user.isActive, state ) ),
// removeAll: очистить коллекцию on(UsersActions.clearUsers, state => usersAdapter.removeAll({ ...state, total: 0 }) ),
// mapOne: трансформация одного элемента on(UsersActions.toggleUserActive, (state, { id }) => usersAdapter.mapOne( { id, map: user => ({ ...user, isActive: !user.isActive }) }, state ) ),
// map: трансформация всех элементов on(UsersActions.deactivateAll, state => usersAdapter.map(user => ({ ...user, isActive: false }), state) ),
on(UsersActions.loadUsersFailure, (state, { error }) => ({ ...state, loading: false, error })),);Встроенные селекторы
Заголовок раздела «Встроенные селекторы»createEntityAdapter возвращает готовые селекторы:
import { createFeatureSelector, createSelector } from '@ngrx/store';import { usersAdapter, UsersState } from './users.reducer';
export const selectUsersState = createFeatureSelector<UsersState>('users');
// Получаем встроенные selectors из адаптераexport const { selectIds: selectUserIds, // ids[] selectEntities: selectUserEntities, // {id: User} dictionary selectAll: selectAllUsers, // User[] (отсортированный по sortComparer) selectTotal: selectUsersCount, // number} = usersAdapter.getSelectors(selectUsersState);
// Дополнительные кастомные селекторыexport const selectUsersLoading = createSelector( selectUsersState, state => state.loading);
export const selectSelectedUserId = createSelector( selectUsersState, state => state.selectedUserId);
export const selectSelectedUser = createSelector( selectUserEntities, selectSelectedUserId, (entities, selectedId) => selectedId ? entities[selectedId] ?? null : null);
// Быстрый поиск по ID через entities (O(1))export const selectUserById = (id: string) => createSelector(selectUserEntities, entities => entities[id] ?? null);
export const selectActiveUsers = createSelector( selectAllUsers, users => users.filter(u => u.isActive));Пример полного flow в компоненте
Заголовок раздела «Пример полного flow в компоненте»@Component({ standalone: true, imports: [AsyncPipe, ...], template: ` @if (loading$ | async) { <mat-spinner /> }
<mat-form-field> <input matInput placeholder="Поиск..." (input)="onSearch($event)" /> </mat-form-field>
<div class="users-count"> Показано: {{ (filteredUsers$ | async)?.length }} из {{ total$ | async }} </div>
@for (user of filteredUsers$ | async; track user.id) { <app-user-card [user]="user" (edit)="onEdit(user)" (delete)="onDelete(user.id)" (toggle)="onToggleActive(user.id)" /> } `})export class UsersPageComponent implements OnInit { private store = inject(Store); private dialog = inject(MatDialog);
loading$ = this.store.select(selectUsersLoading); total$ = this.store.select(selectUsersCount); filteredUsers$ = this.store.select(selectActiveUsers);
ngOnInit() { this.store.dispatch(UsersActions.loadUsers()); }
onToggleActive(id: string) { this.store.dispatch(UsersActions.toggleUserActive({ id })); }
onDelete(id: string) { this.store.dispatch(UsersActions.deleteUser({ id })); }
onEdit(user: User) { const ref = this.dialog.open(UserEditDialogComponent, { data: user }); ref.afterClosed().subscribe(changes => { if (changes) { this.store.dispatch(UsersActions.updateUser({ id: user.id, changes })); } }); }}