40. NgRx: Effects
⚡ NgRx Effects — Побочные эффекты
Заголовок раздела «⚡ NgRx Effects — Побочные эффекты»Effects — это место для всего, что нельзя делать в редьюсере: HTTP-запросы, работа с localStorage, навигация, аналитика. Effects слушают поток actions и могут dispatch-ить новые actions в ответ. Это сердце асинхронной логики в NgRx 🔌
Установка
Заголовок раздела «Установка»npm install @ngrx/effectscreateEffect() — создание эффекта
Заголовок раздела «createEffect() — создание эффекта»import { Injectable } from '@angular/core';import { Actions, createEffect, ofType } from '@ngrx/effects';import { inject } from '@angular/core';import { switchMap, map, catchError, tap, exhaustMap } from 'rxjs/operators';import { of, EMPTY } from 'rxjs';import * as UsersActions from './users.actions';import { UserService } from '../../services/user.service';import { Router } from '@angular/router';
@Injectable()export class UsersEffects { private actions$ = inject(Actions); private userService = inject(UserService); private router = inject(Router);
// Загрузка пользователей loadUsers$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.loadUsers), // Фильтруем только нужный action switchMap(() => // switchMap отменяет предыдущий запрос this.userService.getAll().pipe( map(users => UsersActions.loadUsersSuccess({ users })), catchError(error => of(UsersActions.loadUsersFailure({ error: error.message })) ) ) ) ) );
// Создание пользователя createUser$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.createUser), exhaustMap(({ user }) => // exhaustMap игнорирует новые запросы пока текущий не завершён this.userService.create(user).pipe( map(created => UsersActions.createUserSuccess({ user: created })), catchError(error => of(UsersActions.createUserFailure({ error: error.message })) ) ) ) ) );
// Удаление пользователя deleteUser$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.deleteUser), switchMap(({ id }) => this.userService.delete(id).pipe( map(() => UsersActions.deleteUserSuccess({ id })), catchError(error => of(UsersActions.deleteUserFailure({ error: error.message })) ) ) ) ) );}Actions и ofType()
Заголовок раздела «Actions и ofType()»ofType() — оператор-фильтр, который пропускает только указанные actions:
// Один типofType(loadUsers)
// Несколько типовofType(loginSuccess, loginFromCache)
// Type narrowing — TypeScript понимает тип payloadthis.actions$.pipe( ofType(UsersActions.createUser), // Здесь action автоматически типизирован как { user: UserCreateDto } switchMap(({ user }) => ...))Операторы маппинга в Effects
Заголовок раздела «Операторы маппинга в Effects»Выбор оператора критически важен:
// switchMap — отменяет предыдущий запрос// ✅ Поиск, загрузка данных при смене фильтраloadUsers$ = createEffect(() => this.actions$.pipe( ofType(loadUsers), switchMap(() => this.service.getAll()) ));
// concatMap — очередь, не отменяет предыдущий// ✅ Операции, которые ДОЛЖНЫ выполниться по порядкуsaveItems$ = createEffect(() => this.actions$.pipe( ofType(saveItem), concatMap(({ item }) => this.service.save(item)) ));
// exhaustMap — игнорирует новые пока идёт текущий// ✅ Логин (не дублировать запросы)login$ = createEffect(() => this.actions$.pipe( ofType(login), exhaustMap(({ credentials }) => this.authService.login(credentials)) ));
// mergeMap — параллельно, все запросы// ✅ Независимые операции (загрузка файлов)uploadFile$ = createEffect(() => this.actions$.pipe( ofType(uploadFile), mergeMap(({ file }) => this.service.upload(file)) ));Non-dispatching Effects
Заголовок раздела «Non-dispatching Effects»Иногда эффект не должен dispatch-ить новый action (навигация, аналитика, localStorage):
// Навигация после успешного логинаnavigateAfterLogin$ = createEffect(() => this.actions$.pipe( ofType(loginSuccess), tap(() => this.router.navigate(['/dashboard'])) ), { dispatch: false } // ← важно!);
// Сохранение в localStoragesaveToStorage$ = createEffect(() => this.actions$.pipe( ofType(setTheme, setLanguage), tap(action => { localStorage.setItem('app_settings', JSON.stringify(action)); }) ), { dispatch: false });
// АналитикаtrackPageView$ = createEffect(() => this.router.events.pipe( filter(event => event instanceof NavigationEnd), tap(event => { analytics.track('page_view', { url: (event as NavigationEnd).url }); }) ), { dispatch: false });EMPTY — завершение потока без action
Заголовок раздела «EMPTY — завершение потока без action»import { EMPTY } from 'rxjs';
// Иногда нужно ничего не dispatch-ить при определённых условияхloadIfNotCached$ = createEffect(() => this.actions$.pipe( ofType(loadUsers), withLatestFrom(this.store.select(selectUsersLoaded)), switchMap(([, alreadyLoaded]) => { if (alreadyLoaded) { return EMPTY; // Данные уже есть — ничего не делаем } return this.userService.getAll().pipe( map(users => loadUsersSuccess({ users })), catchError(error => of(loadUsersFailure({ error: error.message }))) ); }) ));Router Effects
Заголовок раздела «Router Effects»import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store';
@Injectable()export class RouterEffects { private actions$ = inject(Actions);
// Загружаем данные при навигации на страницу пользователя loadUserOnNavigation$ = createEffect(() => this.actions$.pipe( ofType(ROUTER_NAVIGATED), map((action: RouterNavigatedAction) => action.payload.routerState), filter(routerState => routerState.url.startsWith('/users/') ), map(routerState => { const id = routerState.root.firstChild?.params['id']; return loadUserById({ id }); }) ) );}Effect Lifecycle Hooks
Заголовок раздела «Effect Lifecycle Hooks»import { OnInitEffects, OnRunEffects, EffectNotification } from '@ngrx/effects';import { Observable } from 'rxjs';
@Injectable()export class AppEffects implements OnInitEffects, OnRunEffects { // Вызывается при регистрации модуля Effects ngrxOnInitEffects(): Action { return initApp(); // dispatch action при старте }
// Оборачивает все эффекты — можно добавить retry логику ngrxOnRunEffects( resolvedEffects$: Observable<EffectNotification> ): Observable<EffectNotification> { return resolvedEffects$.pipe( tap(notification => { if (notification.kind === 'E') { console.error('Effect error:', notification.error); } }) ); }}Регистрация Effects
Заголовок раздела «Регистрация Effects»import { provideEffects } from '@ngrx/effects';import { UsersEffects } from './store/users/users.effects';import { AuthEffects } from './store/auth/auth.effects';
export const appConfig: ApplicationConfig = { providers: [ provideStore(reducers), provideEffects(UsersEffects, AuthEffects), // Feature effects регистрируются через provideEffects в routes ]};
// Для feature (lazy loaded):// routes.tsexport const routes: Routes = [{ path: 'admin', loadComponent: () => import('./admin.component'), providers: [ provideEffects(AdminEffects) ]}];Тестирование Effects
Заголовок раздела «Тестирование Effects»describe('UsersEffects', () => { let actions$: Observable<Action>; let effects: UsersEffects; let userService: jasmine.SpyObj<UserService>;
beforeEach(() => { TestBed.configureTestingModule({ providers: [ UsersEffects, provideMockActions(() => actions$), { provide: UserService, useValue: jasmine.createSpyObj('UserService', ['getAll']) } ] });
effects = TestBed.inject(UsersEffects); userService = TestBed.inject(UserService) as any; });
it('should dispatch loadUsersSuccess on success', () => { const users = [{ id: '1', name: 'Test' }]; userService.getAll.and.returnValue(of(users));
actions$ = hot('-a-', { a: loadUsers() }); const expected = cold('-b-', { b: loadUsersSuccess({ users }) });
expect(effects.loadUsers$).toBeObservable(expected); });});