39. NgRx: Store и Reducers
🏪 NgRx Store — Actions, Reducers, Selectors
Заголовок раздела «🏪 NgRx Store — Actions, Reducers, Selectors»Это практический урок по NgRx Store. Здесь ты напишешь полный цикл: от создания action creators до подписки на селекторы в компоненте. NgRx — это строгая архитектура, которая окупается на больших проектах 💪
Установка NgRx Store
Заголовок раздела «Установка NgRx Store»ng add @ngrx/storenpm install @ngrx/store @ngrx/store-devtoolscreateAction() — создание действий
Заголовок раздела «createAction() — создание действий»createAction() создаёт type-safe фабрику actions. Это гораздо лучше строковых констант:
import { createAction, props } from '@ngrx/store';import { User, UserCreateDto } from '../../models/user.model';
// Actions без пейлоадаexport const loadUsers = createAction('[Users Page] Load Users');export const clearUsers = createAction('[Users Page] Clear Users');
// Actions с пейлоадом через props<T>()export const loadUsersSuccess = createAction( '[Users API] Load Users Success', props<{ users: User[]; total: number }>());
export const loadUsersFailure = createAction( '[Users API] Load Users Failure', props<{ error: string }>());
export const createUser = createAction( '[Users Page] Create User', props<{ user: UserCreateDto }>());
export const createUserSuccess = createAction( '[Users API] Create User Success', props<{ user: User }>());
export const updateUser = createAction( '[Users Page] Update User', props<{ id: string; changes: Partial<User> }>());
export const deleteUser = createAction( '[Users Page] Delete User', props<{ id: string }>());
export const selectUser = createAction( '[Users Page] Select User', props<{ userId: string | null }>());
export const setUsersFilter = createAction( '[Users Page] Set Filter', props<{ filter: string }>());
export const setUsersPage = createAction( '[Users Page] Set Page', props<{ page: number; pageSize: number }>());createReducer() и on() — редьюсер
Заголовок раздела «createReducer() и on() — редьюсер»Редьюсер — это чистая функция. Никаких HTTP-запросов, никаких побочных эффектов, только (state, action) => newState:
import { createReducer, on } from '@ngrx/store';import * as UsersActions from './users.actions';import { User } from '../../models/user.model';
export interface UsersState { users: User[]; selectedUserId: string | null; loading: boolean; creating: boolean; error: string | null; total: number; filter: string; page: number; pageSize: number;}
export const initialState: UsersState = { users: [], selectedUserId: null, loading: false, creating: false, error: null, total: 0, filter: '', page: 1, pageSize: 10,};
export const usersReducer = createReducer( initialState,
// Загрузка списка on(UsersActions.loadUsers, state => ({ ...state, loading: true, error: null, })),
on(UsersActions.loadUsersSuccess, (state, { users, total }) => ({ ...state, users, total, loading: false, })),
on(UsersActions.loadUsersFailure, (state, { error }) => ({ ...state, error, loading: false, })),
// Создание пользователя on(UsersActions.createUser, state => ({ ...state, creating: true, })),
on(UsersActions.createUserSuccess, (state, { user }) => ({ ...state, users: [...state.users, user], total: state.total + 1, creating: false, })),
// Обновление (иммутабельно) on(UsersActions.updateUser, (state, { id, changes }) => ({ ...state, users: state.users.map(user => user.id === id ? { ...user, ...changes } : user ), })),
// Удаление on(UsersActions.deleteUser, (state, { id }) => ({ ...state, users: state.users.filter(u => u.id !== id), total: state.total - 1, })),
// Выбор пользователя on(UsersActions.selectUser, (state, { userId }) => ({ ...state, selectedUserId: userId, })),
// Фильтр и пагинация on(UsersActions.setUsersFilter, (state, { filter }) => ({ ...state, filter, page: 1, // Сбрасываем на первую страницу при фильтрации })),
on(UsersActions.setUsersPage, (state, { page, pageSize }) => ({ ...state, page, pageSize, })),
on(UsersActions.clearUsers, () => initialState));Регистрация Store
Заголовок раздела «Регистрация Store»С provideStore() (Standalone)
Заголовок раздела «С provideStore() (Standalone)»import { provideStore } from '@ngrx/store';import { provideStoreDevtools } from '@ngrx/store-devtools';import { usersReducer } from './store/users/users.reducer';import { authReducer } from './store/auth/auth.reducer';
export const appConfig: ApplicationConfig = { providers: [ provideStore({ users: usersReducer, auth: authReducer, }), provideStoreDevtools({ maxAge: 25, logOnly: environment.production, }) ]};Feature Store (Lazy Loading)
Заголовок раздела «Feature Store (Lazy Loading)»import { provideState } from '@ngrx/store';import { adminReducer } from './store/admin.reducer';
export const adminRoutes: Routes = [ { path: 'admin', loadComponent: () => import('./admin.component'), providers: [ provideState({ name: 'admin', reducer: adminReducer }) ] }];createSelector() — селекторы
Заголовок раздела «createSelector() — селекторы»Селекторы — это мемоизированные запросы к Store. Пересчитываются только при изменении зависимостей:
import { createFeatureSelector, createSelector } from '@ngrx/store';import { UsersState } from './users.reducer';
// Базовый feature selectorexport const selectUsersState = createFeatureSelector<UsersState>('users');
// Простые селекторыexport const selectAllUsers = createSelector( selectUsersState, state => state.users);
export const selectUsersLoading = createSelector( selectUsersState, state => state.loading);
export const selectUsersError = createSelector( selectUsersState, state => state.error);
export const selectUsersFilter = createSelector( selectUsersState, state => state.filter);
export const selectSelectedUserId = createSelector( selectUsersState, state => state.selectedUserId);
// Составные селекторы (мемоизированы)export const selectFilteredUsers = createSelector( selectAllUsers, selectUsersFilter, (users, filter) => { if (!filter.trim()) return users; const lowerFilter = filter.toLowerCase(); return users.filter(u => u.name.toLowerCase().includes(lowerFilter) || u.email.toLowerCase().includes(lowerFilter) ); });
export const selectSelectedUser = createSelector( selectAllUsers, selectSelectedUserId, (users, selectedId) => selectedId ? users.find(u => u.id === selectedId) ?? null : null);
export const selectUsersStats = createSelector( selectAllUsers, users => ({ total: users.length, active: users.filter(u => u.isActive).length, admins: users.filter(u => u.role === 'admin').length, }));store.dispatch() и store.select() в компоненте
Заголовок раздела «store.dispatch() и store.select() в компоненте»import { Component, OnInit } from '@angular/core';import { Store } from '@ngrx/store';import { AsyncPipe } from '@angular/common';import * as UsersActions from '../../store/users/users.actions';import * as UsersSelectors from '../../store/users/users.selectors';
@Component({ standalone: true, imports: [AsyncPipe, ...], template: ` <!-- Loading state --> @if (loading$ | async) { <mat-progress-bar mode="indeterminate" /> }
<!-- Error state --> @if (error$ | async; as error) { <app-error-banner [message]="error" /> }
<!-- Filter --> <mat-form-field> <input matInput placeholder="Поиск..." (input)="onFilter($event)" /> </mat-form-field>
<!-- Users list --> @for (user of filteredUsers$ | async; track user.id) { <app-user-card [user]="user" [selected]="(selectedUser$ | async)?.id === user.id" (select)="onSelect(user.id)" (delete)="onDelete(user.id)" /> }
<!-- Stats --> @if (stats$ | async; as stats) { <div class="stats"> Всего: {{ stats.total }} | Активных: {{ stats.active }} </div> } `})export class UsersPageComponent implements OnInit { private store = inject(Store);
// Подписка через async pipe — автоматический unsubscribe loading$ = this.store.select(UsersSelectors.selectUsersLoading); error$ = this.store.select(UsersSelectors.selectUsersError); filteredUsers$ = this.store.select(UsersSelectors.selectFilteredUsers); selectedUser$ = this.store.select(UsersSelectors.selectSelectedUser); stats$ = this.store.select(UsersSelectors.selectUsersStats);
// Или через toSignal() (Angular 16+) // loading = toSignal(this.store.select(UsersSelectors.selectUsersLoading));
ngOnInit() { // Инициируем загрузку при входе на страницу this.store.dispatch(UsersActions.loadUsers()); }
onFilter(event: Event) { const filter = (event.target as HTMLInputElement).value; this.store.dispatch(UsersActions.setUsersFilter({ filter })); }
onSelect(userId: string) { this.store.dispatch(UsersActions.selectUser({ userId })); }
onDelete(id: string) { this.store.dispatch(UsersActions.deleteUser({ id })); }
ngOnDestroy() { // Опционально: очистить состояние при уходе this.store.dispatch(UsersActions.clearUsers()); }}Reducer composition — составные редьюсеры
Заголовок раздела «Reducer composition — составные редьюсеры»// Несколько фичей в одном storeexport interface AppState { users: UsersState; auth: AuthState; ui: UiState;}
// Общий root reducerexport const reducers: ActionReducerMap<AppState> = { users: usersReducer, auth: authReducer, ui: uiReducer,};
// Meta-reducer: логирование всех actionsexport function logger( reducer: ActionReducer<AppState>): ActionReducer<AppState> { return (state, action) => { console.group(action.type); console.log('prev state', state); const result = reducer(state, action); console.log('next state', result); console.groupEnd(); return result; };}
export const metaReducers: MetaReducer<AppState>[] = [logger];