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

41. NgRx: Selectors

🔬 NgRx Selectors — Мемоизированные запросы к Store

Заголовок раздела «🔬 NgRx Selectors — Мемоизированные запросы к Store»

Selectors — это чистые функции для извлечения кусков состояния из Store. Главная суперсила: мемоизация. Если входные данные не изменились, селектор возвращает закешированный результат и компонент не перерисовывается 🚀


store/users/users.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UsersState } from './users.reducer';
// createFeatureSelector — точка входа для feature state
export const selectUsersState = createFeatureSelector<UsersState>('users');
// createSelector — производный селектор с мемоизацией
export const selectAllUsers = createSelector(
selectUsersState,
(state: UsersState) => state.users
);
export const selectUsersLoading = createSelector(
selectUsersState,
state => state.loading
);
export const selectUsersError = createSelector(
selectUsersState,
state => state.error
);
export const selectUsersTotal = createSelector(
selectUsersState,
state => state.total
);
export const selectSelectedUserId = createSelector(
selectUsersState,
state => state.selectedUserId
);
export const selectUsersPage = createSelector(
selectUsersState,
state => ({ page: state.page, pageSize: state.pageSize })
);

Мощь селекторов — в их composability. Несколько простых селекторов → сложная производная логика:

// Выбор конкретного пользователя из массива
export const selectSelectedUser = createSelector(
selectAllUsers,
selectSelectedUserId,
(users, selectedId) =>
selectedId ? users.find(u => u.id === selectedId) ?? null : null
);
// Фильтрация с мемоизацией
export const selectUsersFilter = createSelector(
selectUsersState,
state => state.filter
);
export const selectFilteredUsers = createSelector(
selectAllUsers,
selectUsersFilter,
(users, filter) => {
if (!filter.trim()) return users;
const lower = filter.toLowerCase();
return users.filter(u =>
u.name.toLowerCase().includes(lower) ||
u.email.toLowerCase().includes(lower) ||
u.role.toLowerCase().includes(lower)
);
}
// Пересчитывается ТОЛЬКО если users или filter изменились
);
// Пагинированный результат
export const selectPaginatedUsers = createSelector(
selectFilteredUsers,
selectUsersPage,
(users, { page, pageSize }) => {
const start = (page - 1) * pageSize;
return users.slice(start, start + pageSize);
}
);
// Статистика
export const selectUsersStats = createSelector(
selectAllUsers,
users => ({
total: users.length,
active: users.filter(u => u.isActive).length,
admins: users.filter(u => u.role === 'admin').length,
byDepartment: users.reduce((acc, u) => {
acc[u.department] = (acc[u.department] || 0) + 1;
return acc;
}, {} as Record<string, number>),
})
);

Иногда нужно комбинировать данные из разных feature states:

store/dashboard/dashboard.selectors.ts
import { selectAllUsers } from '../users/users.selectors';
import { selectAllOrders } from '../orders/orders.selectors';
import { selectCurrentUser } from '../auth/auth.selectors';
// Комбинируем из нескольких feature stores
export const selectDashboardData = createSelector(
selectAllUsers,
selectAllOrders,
selectCurrentUser,
(users, orders, currentUser) => ({
usersCount: users.length,
ordersCount: orders.length,
myOrders: orders.filter(o => o.userId === currentUser?.id),
recentActivity: orders
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 5),
})
);

// Фабрика возвращает новый селектор для каждого параметра
export const selectUserById = (userId: string) =>
createSelector(
selectAllUsers,
users => users.find(u => u.id === userId) ?? null
);
// В компоненте:
// Нельзя вызывать selectUserById() прямо в шаблоне —
// это создаст новый селектор при каждом рендере
// Правильно:
@Component({})
export class UserDetailComponent {
private store = inject(Store);
@Input() set userId(id: string) {
this.user$ = this.store.select(selectUserById(id));
}
user$!: Observable<User | null>;
}

Через MemoizedSelectorWithProps (устаревший способ для справки)

Заголовок раздела «Через MemoizedSelectorWithProps (устаревший способ для справки)»
// Устарело начиная с NgRx 12, используй factory function выше
// export const selectUserById = createSelector(
// selectAllUsers,
// (users: User[], props: { id: string }) =>
// users.find(u => u.id === props.id) ?? null
// );

// Внутри createSelector используется projector function
// Результат кешируется на основе ссылочного равенства входных аргументов
const selectA = createSelector(selectState, state => state.a);
const selectB = createSelector(selectState, state => state.b);
const selectSum = createSelector(
selectA,
selectB,
(a, b) => {
console.log('Пересчёт!'); // Вызовется только при изменении a или b
return a + b;
}
);
// dispatch({ type: '[UI] Toggle Sidebar' }) — состояние a и b не изменились
// → selectSum НЕ пересчитывается, компонент НЕ перерисовывается ✅
// dispatch(updateA({ a: 10 })) — a изменилось
// → selectSum пересчитывается ✅

// Если нужно принудительно сбросить кеш
selectFilteredUsers.release();
// Полезно в тестах или при изменении параметров фабричного селектора

@Component({
standalone: true,
imports: [AsyncPipe, ...],
template: `
@if (loading$ | async) {
<mat-spinner />
}
@if (stats$ | async; as stats) {
<div class="stats-bar">
<span>Всего: {{ stats.total }}</span>
<span>Активных: {{ stats.active }}</span>
<span>Администраторов: {{ stats.admins }}</span>
</div>
}
@for (user of paginatedUsers$ | async; track user.id) {
<app-user-row [user]="user" />
}
`
})
export class UsersTableComponent {
private store = inject(Store);
loading$ = this.store.select(selectUsersLoading);
paginatedUsers$ = this.store.select(selectPaginatedUsers);
stats$ = this.store.select(selectUsersStats);
}
@Component({
template: `
@if (loading()) { <mat-spinner /> }
<div class="stats">{{ stats().total }} пользователей</div>
@for (user of paginatedUsers(); track user.id) {
<app-user-row [user]="user" />
}
`
})
export class UsersTableComponent {
private store = inject(Store);
// Автоматический unsubscribe при уничтожении компонента
loading = toSignal(this.store.select(selectUsersLoading), { initialValue: false });
paginatedUsers = toSignal(this.store.select(selectPaginatedUsers), { initialValue: [] });
stats = toSignal(this.store.select(selectUsersStats), {
initialValue: { total: 0, active: 0, admins: 0 }
});
}

// Можно добавить tap() для отладки без изменения логики
export const selectFilteredUsers = createSelector(
selectAllUsers,
selectUsersFilter,
(users, filter) => {
const result = filter
? users.filter(u => u.name.includes(filter))
: users;
// Только в dev mode:
if (!environment.production) {
console.log('[selectFilteredUsers] Recalculated:', result.length);
}
return result;
}
);