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

42. NgRx: Entity

NgRx Entity — это надстройка над Store для работы с нормализованными коллекциями объектов. Вместо того чтобы писать одни и те же CRUD-редьюсеры снова и снова, Entity даёт тебе готовые операции за несколько строк кода 📦


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

EntityState — структура нормализованной коллекции

Заголовок раздела «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 уникален)
  • Простые обновления без перебора массива

store/users/users.reducer.ts
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { User } from '../../models/user.model';
// Создаём адаптер для типа User
export 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: '',
});

store/users/users.reducer.ts
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 возвращает готовые селекторы:

store/users/users.selectors.ts
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)
);

@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 }));
}
});
}
}