Quản lý State bên trong Angular bằng NgRx

NgRx là hệ sinh thái quản lý state reactive trong Angular. Chương này giúp bạn nắm vững cách sử dụng các phần chính của NgRx để xây dựng ứng dụng có cấu trúc rõ ràng, dễ kiểm soát và mở rộng. Các kỹ thuật được trình bày:

  • Tạo store đầu tiên với actions và reducers
  • Debug state bằng Devtools
  • Sử dụng selectors để lấy dữ liệu từ store
  • Tích hợp effects để gọi API
  • Dùng Component Store để quản lý state cục bộ cho component

✅ 1. Store đầu tiên với Actions & Reducers

  • Mục tiêu: Tạo store cho tính năng thêm/xóa trái cây khỏi bucket.
  • Các bước:
    • Cài @ngrx/store (nếu cần).
    • Cấu hình provideStore({}) trong app.config.ts.
    • Tạo file bucket.actions.ts dùng createActionGroup
    • Tạo reducer trong bucket.reducer.ts
    • Cập nhật app.config.ts: provideStore({ bucket: bucketReducer })
    • Trong component: this.store.dispatch(BucketActions.addFruit({ fruit: newFruit }));
//app.config.ts
...
import { provideStore } from '@ngrx/store';
...
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideStore } from '@ngrx/store';
export const appConfig: ApplicationConfig = {
  providers: [
    ...,
    provideAnimations(),
    provideStore({
      bucket: bucketReducer,
    }),
  ],
};

//bucket.actions.ts
...
export const BucketActions = createActionGroup({
  source: 'Bucket',
  events: {
    'Add Fruit': props<{ fruit: IFruit }>(),
    'Remove Fruit': props<{ fruitId: number }>(),
  },
});

//bucket.reducer.ts
...
import { createReducer, on } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
import { BucketActions } from './bucket.actions';
export const initialState: ReadonlyArray<IFruit> = [];
...
export const bucketReducer = createReducer(
  initialState,
  on(BucketActions.addFruit, (_state, { fruit }) => {
    console.log({ fruit });
    return [fruit, ..._state];
  }),
  on(BucketActions.removeFruit, (_state, { fruitId }) => {
    console.log({ fruitId });
    return _state.filter((fr) => fr.id === fruitId);
  })
);

Ghi nhớ: Reducer không được mutate state. Hành vi được thể hiện qua console log để debug dễ hơn.


🔧 2. Debug với NgRx Store Devtools

  • Mục tiêu: Quan sát mọi action và state thay đổi qua thời gian.
  • Cách thực hiện:
    • Cài @ngrx/store-devtools
    • Thêm vào app.config.ts:provideStoreDevtools({ maxAge: 50 })
    • Cài Redux Devtools Extension
    • Khi chạy app, bật Chrome DevTools → tab Redux:
      • @@INIT → Khởi tạo store
      • Add Fruit → thêm item
      • Remove Fruit → xóa item

Highlight:

  • Màu xanh = thêm state
  • Màu đỏ + gạch ngang = xóa state

📃 3. Render dữ liệu bằng Selectors

  • Vấn đề: Dù dùng NgRx để lưu state, UI vẫn dùng BucketService để render.
  • Giải pháp:
    • Tạo file bucket.selectors.ts
    • Trong component: $bucket = this.store.select(selectBucket);
    • Xoá toàn bộ xử lý state trong BucketService
import { createFeatureSelector } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
export const selectBucket = createFeatureSelector<ReadonlyArray<IFruit>>('bucket');
...
...
import { selectBucket } from '../store/bucket.selectors';
  ...
export class BucketComponent implements OnInit {
  ...
  store = inject(Store);
  $bucket: Observable<IFruit[]> = this.store.select(
    selectBucket);
  
  ngOnInit(): void {
    this.bucketService.loadItems();
  }
  ...
}
...
import { Injectable } from '@angular/core';
import { IFruit } from '../interfaces/fruit.interface';
import { IBucketService } from '../interfaces/bucket-service';
...
export interface IBucketService {
  loadItems(): void;
  saveItems(fruit: IFruit[]): void;
}
...
@Injectable({
  providedIn: 'root',
})
export class BucketService implements IBucketService {
  storeKey = 'bucket_ngrx-selectors';
  loadItems() {
    return JSON.parse(window.localStorage.getItem(this.storeKey) || '[]');
  }
  saveItems(items: IFruit[]) {
    window.localStorage.setItem(this.storeKey, JSON.stringify(items));
  }
}

Ví dụ template:

<div *ngFor="let item of $bucket | async">
  {{ item.name }}
</div>

Ghi nhớ: Selectors giúp component “mỏng”, chỉ lo phần hiển thị, không quan tâm logic store.


🚀 4. Gọi API với NgRx Effects

  • Mục tiêu: Kết nối backend để load/add/remove dữ liệu bucket.
  • Cách làm:
    • Cài đặt:
      npm install –save @ngrx/effects
    • Tạo thêm các action
    • Tạo bucket.effects.ts
    • Dispatch khi khởi tạo component:
      ngOnInit() { this.store.dispatch(BucketActions.getBucket()); }
    • Sửa reducer để lắng nghe success actions
import { createActionGroup, props, emptyProps } from '@ngrx/store';
import { IFruit } from '../interfaces/fruit.interface';
export const BucketActions = createActionGroup({
  source: 'Bucket',
  events: {
    'Get Bucket': emptyProps(),
    'Get Bucket Success': props<{ bucket: IFruit[] }>(),
    'Get Bucket Failure': props<{ error: string }>(),
    'Add Fruit': props<{ fruit: IFruit }>(),
    'Add Fruit Success': props<{ fruit: IFruit }>(),
    'Add Fruit Failure': props<{ error: string }>(),
    'Remove Fruit': props<{ fruitId: number }>(),
    'Remove Fruit Success': props<{ fruitId: number }>(),
    'Remove Fruit Failure': props<{ error: string }>(),
  },
});
//bucket.effects.ts
import { Injectable } from '@angular/core';
import { Actions, ofType, createEffect } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, exhaustMap, map } from 'rxjs/operators';
import { BucketService } from '../bucket/bucket.service';
import { BucketActions } from './bucket.actions';
@Injectable()
export class BucketEffects {
  getBucket$ = createEffect(() =>
    this.actions$.pipe(
      ofType(BucketActions.getBucket),
      exhaustMap(() =>
        this.bucketService.getBucket().pipe(
          map(({ bucket }) => BucketActions
            .getBucketSuccess({ bucket })),
          catchError((error) => of(BucketActions
            .getBucketFailure({ error })))
        )
      )
    )
  );
  constructor(
    private actions$: Actions,
    private bucketService: BucketService
  ) {}
}
//app.config.ts
...
import { provideEffects } from '@ngrx/effects';
...
import { BucketEffects } from './app/store/bucket.effects';
...
bootstrapApplication(AppComponent, {
  providers: [
    ...,
    provideHttpClient(),
    provideEffects([BucketEffects]),
  ],
}).catch((err) => console.error(err));
...
//bucket.reducer.ts
export const initialState: ReadonlyArray<IFruit> = [];
export const bucketReducer = createReducer(
  initialState,
  on(BucketActions.getBucketSuccess,
    (_state, { bucket }) => {
    return bucket;
  }),
  on(BucketActions.addFruitSuccess, (_state, { fruit }) => {
    console.log({ fruit });
    return [fruit, ..._state];
  }),
  on(BucketActions.removeFruitSuccess, (_state, { fruitId }) => {
    console.log({ fruitId });
    return _state.filter((fr) => fr.id !== fruitId);
  })
);

Ghi nhớ: Dùng 3 actions cho mỗi call: trigger → success → failure. Giúp dễ debug và tách biệt luồng dữ liệu.


🔹 5. Component Store: quản lý state riêng cho component

  • Tình huống: Chỉ cần state cho 1 component, không cần chia sẻ toàn ứng dụng.
  • Cách triển khai:
    • Kế thừa ComponentStore<BucketState> trong service: export interface BucketState { bucket: IFruit[]; }
    • Dùng this.setState() để init từ localStorage.
    • Tạo selectors và updater
    • Dùng trong component như sau:this.store.addItem(newFruit);
...
import { ComponentStore } from '@ngrx/component-store';
import { IFruit } from '../interfaces/fruit.interface';
export interface BucketState {
  bucket: IFruit[];
}
...
export class BucketService extends ComponentStore<BucketState> {
  storeKey = 'bucket_ngrx-component-store';
  readonly bucket$: Observable<IFruit[]> = this.select((state) => state.bucket);

  constructor() {
    super({ bucket: [] });
    this.setState({
      bucket: this.loadItems(),
    });
  }

  readonly addItem = this.updater((state: BucketState,
    fruit: IFruit) => {
    const bucketUpdated = [fruit, ...state.bucket];
    this.saveItems(bucketUpdated);
    return {
      bucket: bucketUpdated,
    };
  });

  readonly removeItem = this.updater((state: BucketState,
    fruitId: number) => {
    const bucketUpdated = state.bucket.filter((fr) =>
      fr.id !== fruitId);
    this.saveItems(bucketUpdated);
    return {
      bucket: bucketUpdated,
    };
  });
  ...
  loadItems() {...}
  saveItems(items: IFruit[]) {...}

}
...
import { BucketService } from './bucket.service';
@Component({...})
export class BucketComponent {
  ...
  bucket: IFruit[] = []; //← remove
  store = inject(BucketService); //← add
  bucket$ = this.store.bucket$; //← add
  addSelectedFruitToBucket() {
    const newFruit: IFruit = {
      id: Date.now(),
      name: this.selectedFruit,
    };
    this.store.addItem(newFruit);
  }
  deleteFromBucket(fruit: IFruit) {
    this.store.removeItem(fruit.id);
  }
}
  <div class="fruits" *ngIf="bucket$ | async as bucket"
    [@listItemAnimation]="bucket.length">
    <ng-container *ngIf="bucket.length > 0; else
      bucketEmptyMessage">
    ...
    </ng-container>
    <ng-template #bucketEmptyMessage>...</ng-template>
  </div>

Ghi nhớ: Component Store giúp cô lập state, dễ test, nhẹ hơn store toàn cục.


🔗 Tổng kết chương

Kỹ thuậtCông cụMục đích
Actions, Reducers@ngrx/storeCập nhật state
Devtools@ngrx/store-devtoolsDebug actions và state
SelectorscreateFeatureSelectorLấy dữ liệu từ state
Effects@ngrx/effectsXử lý bất đồng bộ/API
Component Store@ngrx/component-storeQuản lý state cục bộ

Quản lý state tốt là nền tảng để scale ứng dụng Angular một cách dễ dàng và có tổ chức.

Để lại một bình luận