Настройка State Management (NgRx) для Angular-приложения
NgRx — Redux-архитектура для Angular, построенная на RxJS. Однонаправленный поток данных: Actions → Reducers → State → Selectors → Components → Actions. Дополнительно — Effects для сайд-эффектов (HTTP, WebSocket, localStorage) и Entity для работы с коллекциями.
Оправдан в крупных корпоративных Angular-приложениях с несколькими командами, сложной бизнес-логикой и требованием полной трассируемости изменений. Для небольших проектов использование NgRx сопряжено с избыточным boilerplate.
Что входит в работу
Установка пакетов NgRx, настройка Store, Actions, Reducers, Selectors, Effects. Интеграция с Angular Router через @ngrx/router-store. Feature-модули с lazy loaded state. NgRx Entity для коллекций. DevTools. Тестирование.
Установка
ng add @ngrx/store@latest
ng add @ngrx/effects@latest
ng add @ngrx/entity@latest
ng add @ngrx/store-devtools@latest
ng add @ngrx/router-store@latest
# или через npm
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools @ngrx/router-store
ng add автоматически обновляет app.module.ts или app.config.ts (standalone).
Структура файлов
src/app/
store/
app.state.ts # корневой интерфейс состояния
features/
products/
store/
products.actions.ts
products.reducer.ts
products.selectors.ts
products.effects.ts
products.facade.ts # опционально
products.module.ts
Actions
// features/products/store/products.actions.ts
import { createAction, createActionGroup, emptyProps, props } from '@ngrx/store'
export const ProductsActions = createActionGroup({
source: 'Products',
events: {
'Load Products': props<{ categoryId: string }>(),
'Load Products Success': props<{ products: Product[] }>(),
'Load Products Failure': props<{ error: string }>(),
'Select Product': props<{ productId: string }>(),
'Add To Cart': props<{ product: Product }>(),
'Clear Selection': emptyProps(),
},
})
createActionGroup генерирует типизированные экшены с автоматическим type string: [Products] Load Products.
State и Reducer с NgRx Entity
// features/products/store/products.reducer.ts
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'
import { createReducer, on } from '@ngrx/store'
import { ProductsActions } from './products.actions'
export interface ProductsState extends EntityState<Product> {
selectedProductId: string | null
loading: boolean
error: string | null
}
export const adapter: EntityAdapter<Product> = createEntityAdapter<Product>({
selectId: (p) => p.id,
sortComparer: (a, b) => a.name.localeCompare(b.name),
})
const initialState: ProductsState = adapter.getInitialState({
selectedProductId: null,
loading: false,
error: null,
})
export const productsReducer = createReducer(
initialState,
on(ProductsActions.loadProducts, (state) => ({
...state,
loading: true,
error: null,
})),
on(ProductsActions.loadProductsSuccess, (state, { products }) =>
adapter.setAll(products, { ...state, loading: false })
),
on(ProductsActions.loadProductsFailure, (state, { error }) => ({
...state,
loading: false,
error,
})),
on(ProductsActions.selectProduct, (state, { productId }) => ({
...state,
selectedProductId: productId,
})),
on(ProductsActions.clearSelection, (state) => ({
...state,
selectedProductId: null,
}))
)
export const { selectAll, selectEntities, selectIds, selectTotal } =
adapter.getSelectors()
Selectors
// features/products/store/products.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store'
import { ProductsState, selectAll, selectEntities } from './products.reducer'
export const selectProductsState =
createFeatureSelector<ProductsState>('products')
export const selectAllProducts = createSelector(
selectProductsState,
selectAll
)
export const selectProductsLoading = createSelector(
selectProductsState,
(state) => state.loading
)
export const selectProductsError = createSelector(
selectProductsState,
(state) => state.error
)
export const selectSelectedProductId = createSelector(
selectProductsState,
(state) => state.selectedProductId
)
export const selectSelectedProduct = createSelector(
selectProductsState,
selectEntities,
selectSelectedProductId,
(_, entities, id) => (id ? entities[id] ?? null : null)
)
export const selectProductsByCategory = (categoryId: string) =>
createSelector(selectAllProducts, (products) =>
products.filter((p) => p.categoryId === categoryId)
)
Effects
// features/products/store/products.effects.ts
import { Injectable } from '@angular/core'
import { Actions, createEffect, ofType } from '@ngrx/effects'
import { catchError, map, switchMap } from 'rxjs/operators'
import { of } from 'rxjs'
import { ProductsActions } from './products.actions'
import { ProductsService } from '../products.service'
@Injectable()
export class ProductsEffects {
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductsActions.loadProducts),
switchMap(({ categoryId }) =>
this.productsService.getByCategory(categoryId).pipe(
map((products) => ProductsActions.loadProductsSuccess({ products })),
catchError((err) =>
of(ProductsActions.loadProductsFailure({ error: err.message }))
)
)
)
)
)
// эффект без диспатча (навигация, analytics)
trackProductView$ = createEffect(
() =>
this.actions$.pipe(
ofType(ProductsActions.selectProduct),
tap(({ productId }) => this.analytics.track('product_view', { productId }))
),
{ dispatch: false }
)
constructor(
private actions$: Actions,
private productsService: ProductsService,
private analytics: AnalyticsService
) {}
}
Регистрация в модуле
// products.module.ts
import { StoreModule } from '@ngrx/store'
import { EffectsModule } from '@ngrx/effects'
@NgModule({
imports: [
StoreModule.forFeature('products', productsReducer),
EffectsModule.forFeature([ProductsEffects]),
],
})
export class ProductsModule {}
Для standalone-приложений (Angular 17+):
// app.config.ts
import { provideStore } from '@ngrx/store'
import { provideEffects } from '@ngrx/effects'
import { provideStoreDevtools } from '@ngrx/store-devtools'
export const appConfig: ApplicationConfig = {
providers: [
provideStore(),
provideEffects(),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
],
}
Использование в компоненте
@Component({
selector: 'app-products',
template: `
<div *ngIf="loading$ | async">Загрузка...</div>
<app-product-card
*ngFor="let product of products$ | async"
[product]="product"
(addToCart)="onAddToCart($event)"
/>
`,
})
export class ProductsComponent implements OnInit {
products$ = this.store.select(selectAllProducts)
loading$ = this.store.select(selectProductsLoading)
error$ = this.store.select(selectProductsError)
constructor(
private store: Store,
private route: ActivatedRoute
) {}
ngOnInit() {
this.route.params.pipe(
map((p) => p['categoryId']),
distinctUntilChanged()
).subscribe((categoryId) => {
this.store.dispatch(ProductsActions.loadProducts({ categoryId }))
})
}
onAddToCart(product: Product) {
this.store.dispatch(ProductsActions.addToCart({ product }))
}
}
Facade-паттерн
Фасад скрывает NgRx от компонентов — компонент работает только с фасадом:
@Injectable({ providedIn: 'root' })
export class ProductsFacade {
allProducts$ = this.store.select(selectAllProducts)
loading$ = this.store.select(selectProductsLoading)
selectedProduct$ = this.store.select(selectSelectedProduct)
constructor(private store: Store) {}
loadProducts(categoryId: string) {
this.store.dispatch(ProductsActions.loadProducts({ categoryId }))
}
selectProduct(productId: string) {
this.store.dispatch(ProductsActions.selectProduct({ productId }))
}
addToCart(product: Product) {
this.store.dispatch(ProductsActions.addToCart({ product }))
}
}
NgRx Router Store
// app.config.ts
import { provideRouterStore, routerReducer } from '@ngrx/router-store'
providers: [
provideStore({ router: routerReducer }),
provideRouterStore(),
]
// selectors
import { getSelectors } from '@ngrx/router-store'
const { selectCurrentRoute, selectQueryParams, selectRouteParams } = getSelectors()
// использование в эффекте
this.store.select(selectRouteParams).pipe(
map((params) => params['id']),
filter(Boolean),
switchMap((id) => this.service.getById(id))
)
Тестирование
// reducer
describe('productsReducer', () => {
it('устанавливает loading при loadProducts', () => {
const state = productsReducer(initialState, ProductsActions.loadProducts({ categoryId: '1' }))
expect(state.loading).toBe(true)
})
it('записывает products при успехе', () => {
const products = [{ id: '1', name: 'Test', price: 100, categoryId: '1' }]
const state = productsReducer(
{ ...initialState, loading: true },
ProductsActions.loadProductsSuccess({ products })
)
expect(state.ids).toEqual(['1'])
expect(state.loading).toBe(false)
})
})
// effects
describe('ProductsEffects', () => {
let effects: ProductsEffects
let actions$: Observable<Action>
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ProductsEffects,
provideMockActions(() => actions$),
{ provide: ProductsService, useValue: { getByCategory: jest.fn() } },
],
})
effects = TestBed.inject(ProductsEffects)
})
it('dispatches loadProductsSuccess', () => {
const products = [{ id: '1', name: 'Test', price: 100, categoryId: '1' }]
const service = TestBed.inject(ProductsService) as jest.Mocked<ProductsService>
service.getByCategory.mockReturnValue(of(products))
actions$ = hot('-a', { a: ProductsActions.loadProducts({ categoryId: '1' }) })
const expected = cold('-b', { b: ProductsActions.loadProductsSuccess({ products }) })
expect(effects.loadProducts$).toBeObservable(expected)
})
})
Что делаем
Устанавливаем и настраиваем весь NgRx-стек, проектируем feature-сторы под предметную область, реализуем эффекты для HTTP-запросов, настраиваем router-store, при необходимости оборачиваем в Facade, покрываем тестами reducers и effects.
Срок: 5–10 дней в зависимости от количества feature-модулей и сложности бизнес-логики.







