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

40. NgRx: Effects

Effects — это место для всего, что нельзя делать в редьюсере: HTTP-запросы, работа с localStorage, навигация, аналитика. Effects слушают поток actions и могут dispatch-ить новые actions в ответ. Это сердце асинхронной логики в NgRx 🔌


Окно терминала
npm install @ngrx/effects

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

ofType() — оператор-фильтр, который пропускает только указанные actions:

// Один тип
ofType(loadUsers)
// Несколько типов
ofType(loginSuccess, loginFromCache)
// Type narrowing — TypeScript понимает тип payload
this.actions$.pipe(
ofType(UsersActions.createUser),
// Здесь action автоматически типизирован как { user: UserCreateDto }
switchMap(({ user }) => ...)
)

Выбор оператора критически важен:

// 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))
)
);

Иногда эффект не должен dispatch-ить новый action (навигация, аналитика, localStorage):

// Навигация после успешного логина
navigateAfterLogin$ = createEffect(() =>
this.actions$.pipe(
ofType(loginSuccess),
tap(() => this.router.navigate(['/dashboard']))
),
{ dispatch: false } // ← важно!
);
// Сохранение в localStorage
saveToStorage$ = 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 }
);

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 })))
);
})
)
);

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 });
})
)
);
}

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);
}
})
);
}
}

app.config.ts
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.ts
export const routes: Routes = [{
path: 'admin',
loadComponent: () => import('./admin.component'),
providers: [
provideEffects(AdminEffects)
]
}];

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);
});
});