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

39. NgRx: Store и Reducers

Это практический урок по NgRx Store. Здесь ты напишешь полный цикл: от создания action creators до подписки на селекторы в компоненте. NgRx — это строгая архитектура, которая окупается на больших проектах 💪


Окно терминала
ng add @ngrx/store
npm install @ngrx/store @ngrx/store-devtools

createAction() создаёт type-safe фабрику actions. Это гораздо лучше строковых констант:

store/users/users.actions.ts
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 }>()
);

Редьюсер — это чистая функция. Никаких HTTP-запросов, никаких побочных эффектов, только (state, action) => newState:

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

app.config.ts
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,
})
]
};
features/admin/admin.routes.ts
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 })
]
}
];

Селекторы — это мемоизированные запросы к Store. Пересчитываются только при изменении зависимостей:

store/users/users.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UsersState } from './users.reducer';
// Базовый feature selector
export 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,
})
);

features/users/users-page.component.ts
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());
}
}

// Несколько фичей в одном store
export interface AppState {
users: UsersState;
auth: AuthState;
ui: UiState;
}
// Общий root reducer
export const reducers: ActionReducerMap<AppState> = {
users: usersReducer,
auth: authReducer,
ui: uiReducer,
};
// Meta-reducer: логирование всех actions
export 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];