41. NgRx: Selectors
🔬 NgRx Selectors — Мемоизированные запросы к Store
Заголовок раздела «🔬 NgRx Selectors — Мемоизированные запросы к Store»Selectors — это чистые функции для извлечения кусков состояния из Store. Главная суперсила: мемоизация. Если входные данные не изменились, селектор возвращает закешированный результат и компонент не перерисовывается 🚀
Базовые селекторы
Заголовок раздела «Базовые селекторы»import { createFeatureSelector, createSelector } from '@ngrx/store';import { UsersState } from './users.reducer';
// createFeatureSelector — точка входа для feature stateexport 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:
import { selectAllUsers } from '../users/users.selectors';import { selectAllOrders } from '../orders/orders.selectors';import { selectCurrentUser } from '../auth/auth.selectors';
// Комбинируем из нескольких feature storesexport 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), }));Селекторы с параметрами
Заголовок раздела «Селекторы с параметрами»Через factory function (рекомендуемый способ)
Заголовок раздела «Через factory function (рекомендуемый способ)»// Фабрика возвращает новый селектор для каждого параметра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();
// Полезно в тестах или при изменении параметров фабричного селектораИспользование в компоненте
Заголовок раздела «Использование в компоненте»С async pipe (классика)
Заголовок раздела «С async pipe (классика)»@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);}С toSignal() (Angular 16+)
Заголовок раздела «С toSignal() (Angular 16+)»@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; });