Développement Web

NgRx Signal Store: إدارة الحالة الحديثة مع Angular

5 min de lecture

لسنوات، اعتمد NgRx على ثلاثيّة actions–reducers–effects موروثة من Redux. متين، لكنّه إسهابيّ: زيادة بسيطة كانت تتطلّب action و type و reducer و selector. وصول signals Angular سمح بإعادة التفكير في هذه الـ stack. NgRx Signal Store هو النتيجة: API مُختصَر، مكتَّب من البداية إلى النهاية، يُكتَب في بضعة أسطر للحالات البسيطة ويمتدّ بالتأليف للحالات المعقّدة. هذا الدرس يبني خطوة بخطوة store لإدارة المنتجات مع البحث والترشيح والتحميل غير المتزامن، ثمّ يُؤلّفه عبر withFeature لإظهار قابليّة التكوين.

المتطلّبات

  • Angular 17 كحدّ أدنى (18+ مُوصى به للاستقرار الكامل للـ signals).
  • المكوّنات standalone مُفعَّلة في مشروعك.
  • فهم أساسيّ لـ signals: signal()، computed()، effect().
  • 30 إلى 45 دقيقة لمتابعة الدرس وتشغيل كلّ اختبار.

الخطوة 1 — تثبيت NgRx Signals

تُثبَّت المكتبة عبر الـ schematic المُخصَّص، الذي يُضيف التبعيّة إلى package.json ويُحدّث الـ imports. NgRx Signals package منفصل عن NgRx Store التاريخيّ: يمكنك استعمالهما معاً (Signal Store للشاشات الجديدة، NgRx Store للكود القائم) أو اختيار Signal Store وحده.

ng add @ngrx/signals

الأمر يكشف إصدار Angular لديك ويُثبّت الإصدار المتوافق. في النهاية، تحقّق من وجود @ngrx/signals في تبعيّاتك. لا module يحتاج تسجيلاً في app.config.ts: Signal Store يعمل بشكل صرف عبر حقن دوال المصنع، ممّا يُقلّل عقبة التبنّي. شغّل ng serve للتأكيد أنّ المشروع لا يزال يُجمَّع.

الخطوة 2 — إنشاء أوّل store بـ withState

الـ store في NgRx Signals يتألّف من blocks تُسمّى features. كلّ feature تُضيف قدرة للـ store. الأكثر أساسيّةً withState، التي تُحدّد الشكل الابتدائيّ للبيانات. النتيجة تُكشَف على شكل signals تلقائيّاً، ممّا يجعل كلّ خاصيّة قابلة للقراءة والتعقّب.

import { signalStore, withState } from '@ngrx/signals';

type ProductState = {
  products: Product[];
  loading: boolean;
  query: string;
};

const initialState: ProductState = {
  products: [],
  loading: false,
  query: '',
};

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
);

الـ store الآن قابل للحقن في أيّ مكان. في مكوّن، تصل إلى القيم عبر store.products() و store.loading() و store.query(). كلّ وصول يُسجَّل في الرسم البياني التفاعليّ: قالب يقرأ store.loading() سيُعاد رسمه تلقائيّاً عند تغيّر الحالة. الخيار providedIn: 'root' يجعل الـ store عامّاً؛ يمكنك أيضاً حذفه لـ store محليّ بإعلان الـ class في مصفوفة providers للمكوّن المستهلك (providers: [ProductStore])، ممّا يُنشئ نسخة لكلّ نسخة من المكوّن.

الخطوة 3 — إضافة قيم مشتقّة بـ withComputed

القيم المشتقّة (ترشيح، عدّ، تحويل) تستفيد من المركَزة في الـ store بدلاً من التبعثر في المكوّنات. withComputed يلعب هذا الدور. يستقبل الـ store أثناء البناء ويُعيد كائناً من signals مُحتسَبة. كلّ احتساب يُنفَّذ فقط حين تتغيّر تبعيّاته، ممّا يتجنّب أيّ إعادة احتساب زائدة.

import { signalStore, withState, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ products, query }) => ({
    filtered: computed(() => {
      const q = query().toLowerCase();
      return products().filter(p => p.name.toLowerCase().includes(q));
    }),
    total: computed(() => products().length),
    hasMatches: computed(() => products().length > 0),
  })),
);

في المكوّن، اقرأ هذه المشتقّات كأيّ signal: store.filtered()، store.total(). تغيّر query يُعيد احتساب filtered و hasMatches فقط، لا total (الذي يعتمد فقط على products). هذه الدقّة تجعل الـ store أداءه عالياً حتى مع مئة مشتقّ. للتحقّق من اشتغال التفاعليّة، افتح DevTools Angular ولاحظ عدّاد change detection: يجب ألّا يزداد إلّا على المكوّنات التي تقرأ signal مُعدَّلاً فعلاً.

الخطوة 4 — تعريف actions بـ withMethods

حيث كان NgRx التقليديّ يفصل actions و reducers، Signal Store يُوحّد كلّ شيء في withMethods. method يمكنه قراءة الحالة، تعديلها عبر patchState، إطلاق side-effect غير متزامن، وكشف النتيجة. النحو يبقى أمريّاً وقابلاً للقراءة — لا switch على نوع action، ولا combine reducers يحتاج صيانة.

import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(/* ... */),
  withMethods((store, http = inject(HttpClient)) => ({
    async load(): Promise<void> {
      patchState(store, { loading: true });
      const data = await firstValueFrom(http.get<Product[]>('/api/products'));
      patchState(store, { products: data, loading: false });
    },
    setQuery(q: string): void {
      patchState(store, { query: q });
    },
    remove(id: number): void {
      patchState(store, { products: store.products().filter(p => p.id !== id) });
    },
  })),
);

ثلاث methods نموذجيّة لـ store CRUD. load() يُحوّل حالة التحميل، يستدعي الـ API، ثمّ يُحدّث القائمة — كلّ ذلك في بضعة أسطر. setQuery يُوضّح بساطة التعديل المباشر. remove يُظهر أنّه يمكن قراءة الحالة الحاليّة (store.products()) لاحتساب القيمة التالية. في المكوّن، الاستعمال يصبح تافهاً: (click)="store.remove(p.id)". للتأكيد، أضف console.log في load() ولاحظ أنّه لا يُستدعى إلّا مرّة واحدة حتى لو استهلكت عدّة مكوّنات store.products().

الخطوة 5 — إدارة المجموعات بـ withEntities

حين نتعامل مع قائمة كيانات مع عمليّات CRUD فرديّة (إضافة، تعديل، حذف حسب id)، الـ plugin @ngrx/signals/entities يتجنّب إعادة اختراع العجلة. يوفّر adapter يُسوّي المجموعة في { entities, ids }، ممّا يجعل lookups حسب id بـ O(1) والتحديثات الجزئيّة أنظف.

import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { withEntities, addEntity, removeEntity, updateEntity, setAllEntities } from '@ngrx/signals/entities';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { inject } from '@angular/core';

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withEntities<Product>(),
  withState({ loading: false }),
  withMethods((store, http = inject(HttpClient)) => ({
    async load() {
      patchState(store, { loading: true });
      const data = await firstValueFrom(http.get<Product[]>('/api/products'));
      patchState(store, setAllEntities(data), { loading: false });
    },
    create(p: Product) { patchState(store, addEntity(p)); },
    update(id: number, changes: Partial<Product>) { patchState(store, updateEntity({ id, changes })); },
    remove(id: number) { patchState(store, removeEntity(id)); },
  })),
);

withEntities<Product>() يُضيف تلقائياً signal اثنين إلى store: entities() (المصفوفة) و entityMap() (الـ lookup حسب id). الـ helpers addEntity و removeEntity و updateEntity و setAllEntities تُرجع patches يطبّقها patchState. إصدار 20 من NgRx يُضيف prependEntity للإدراج في رأس القائمة، مفيد لخيوط نشاط أو سجلّات حيث تمرّ العناصر الحديثة أوّلاً.

الخطوة 6 — تأليف features بـ withFeature

حين يكبر store، الخطر أن يصبح مُجمَّع كلّ شيء. NgRx Signal Store 20 يُدخل withFeature، factory تستقبل الـ store الجاري وتسمح بإنشاء features مرتبطة بالسياق. هذه الأداة المفضّلة لاهتمامات عابرة: pagination، undo/redo، حفظ محليّ، متابعة analytic.

import { signalStore, withState, withMethods, patchState, withFeature } from '@ngrx/signals';

// feature قابلة لإعادة الاستخدام: pagination
function withPagination(pageSize: number) {
  return signalStoreFeature(
    withState({ page: 1, pageSize }),
    withMethods((store) => ({
      next() { patchState(store, { page: store.page() + 1 }); },
      prev() { patchState(store, { page: Math.max(1, store.page() - 1) }); },
    })),
  );
}

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withEntities<Product>(),
  withFeature((s) => withPagination(20)),
);

الـ pagination تصبح لبنة قابلة لإعادة الاستخدام. سبعة أسطر لاحقاً، يمكنك تأليفها في أيّ store يحتاجها: منتجات، طلبات، مستخدمين. withFeature يضمن استقبال الدالّة للـ store أثناء البناء، ممّا يسمح بكتابة features مرتبطة بأجزاء أخرى من الحالة (مثلاً: إعادة احتساب الصفحة الحاليّة عند تغيّر الفلتر). للتحقّق، اكتب اختباراً: أنشئ store اثنين يؤلّفان withPagination وتحقّق أنّهما يحفظان حالتهما باستقلال.

الخطوة 7 — Hooks دورة الحياة بـ withHooks

بعض التهيئات لا تُصرَّح في الحالة الابتدائيّة ولا في method تُستدعى صراحة. تحميل البيانات عند إنشاء الـ store، تسجيل تدميره، مزامنة مع حدث شبكة: withHooks يُغطّي هذه الاحتياجات عبر onInit و onDestroy.

import { signalStore, withState, withMethods, patchState, withHooks } from '@ngrx/signals';

export const ProductStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withMethods(/* ... */),
  withHooks({
    onInit(store) {
      store.load();
    },
    onDestroy(store) {
      console.log('ProductStore مُدمَّر');
    },
  }),
);

onInit يُنفَّذ أوّل مرّة يُنشَأ فيها الـ store — لـ store providedIn: 'root'، عند إنشاء سياق الحقن. onDestroy يُنفَّذ عند تدمير السياق الأب. لـ store محليّ لمكوّن، هذه الـ hooks تتبع دورة المكوّن، ممّا يجعل مثلاً حفظ local storage تافهاً: احفظ في onDestroy، استعد في onInit. راقب الـ console: رسالة « ProductStore مُدمَّر » يجب أن تظهر عند مغادرة الصفحة المُستضيفة لمكوّن يحقن store محليّ.

الخطوة 8 — ربط الـ store بمكوّن

استعمال الـ store في مكوّن standalone تافه: inject(ProductStore) وكلّ شيء متاح. القالب يقرأ الـ signals مباشرة، دون async pipe. الـ methods تُستدعى عند حدث مستخدم. التفاعليّة تُدار بـ change detection لـ signals Angular، ممّا يجعل المكوّن متوافقاً مع نمط zoneless دون تعديل.

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [FormsModule],
  template: `
    <input [ngModel]="store.query()" (ngModelChange)="store.setQuery($event)" placeholder="بحث" />

    @if (store.loading()) {
      <p>جارٍ التحميل...</p>
    } @else {
      @for (p of store.filtered(); track p.id) {
        <article>
          <h3>{{ p.name }}</h3>
          <button (click)="store.remove(p.id)">حذف</button>
        </article>
      } @empty {
        <p>لا منتج موجود.</p>
      }
    }
  `,
})
export class ProductListComponent {
  protected store = inject(ProductStore);
}

لا subscription RxJS يحتاج صيانة، لا async pipe، لا unsubscribe يحتاج إدارة. كلّ قراءة signal تُسجَّل في سياق القالب وتُحرَّر تلقائياً عند تدمير المكوّن. هذا ما يجعل Signal Store ملائماً خصوصاً لنمط zoneless: الـ change detection لم تَعُد بحاجة إلى trigger عامّ، كلّ signal يعرف أيّ مكوّن يجب وَسمه للرسم.

الخطوة 9 — اختبار Signal Store

اختبار store NgRx Signals أبسط من اختبار store كلاسيكيّ. لا حاجة لـ marbles RxJS ولا لـ TestBed ثقيل لمعظم الحالات. تُنشئ الـ store في سياق حقن، تُطلق methods وتقرأ signals كما في مكوّن.

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { patchState } from '@ngrx/signals';
import { ProductStore } from './product.store';

describe('ProductStore', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting(), ProductStore],
    });
  });

  it('يُحدّث الـ query', () => {
    const store = TestBed.inject(ProductStore);
    store.setQuery('clavier');
    expect(store.query()).toBe('clavier');
  });

  it('يُرشّح المنتجات حسب الـ query', () => {
    const store = TestBed.inject(ProductStore);
    patchState(store, { products: [
      { id: 1, name: 'Clavier mécanique' },
      { id: 2, name: 'Souris' },
    ]});
    store.setQuery('clavier');
    expect(store.filtered()).toHaveLength(1);
  });
});

الاختبار يحقن الـ store، يتلاعب بالحالة، ويتحقّق من النتيجة — هذا كلّ شيء. للـ methods غير المتزامنة، علّم الاختبار async واستعمل await عادياً. لا subscription تحتاج إغلاقاً، لا action مزيّفة تحتاج dispatch. شغّل ng test (Vitest منذ Angular 21) ولاحظ سرعة التنفيذ: store متوسّط الحجم يُختبَر في أقلّ من ثانية على Vitest، مقابل عدّة ثوان مع Karma.

الخطوة 10 — الهجرة من NgRx Store الكلاسيكي

إذا كان لديك مشروع قديم بـ NgRx Store، الهجرة يمكن أن تكون تدريجيّة. لا حاجة لإعادة كتابة كلّ شيء دفعة واحدة. الاستراتيجيّة الأنجح في الإنتاج: Signal Store لكلّ الشاشات الجديدة، NgRx Store الكلاسيكيّ للكود القائم، مع جسر بسيط لتقاسم بعض الحالات الحرجة (المستخدم المتّصل مثلاً).

// الجسر: كشف حالة NgRx Store الكلاسيكيّ كـ signal
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';

export const AuthBridge = signalStore(
  { providedIn: 'root' },
  withState({ user: null as User | null }),
  withHooks({
    onInit(store, ngrxStore = inject(Store)) {
      const user = toSignal(ngrxStore.select(selectUser), { initialValue: null });
      effect(() => patchState(store, { user: user() }));
    },
  }),
);

هذا الجسر يكشف حالة الاستيثاق (المخزَّنة في NgRx Store التاريخيّ) كـ signal قابل للقراءة من قِبَل أيّ Signal Store. الهجرة يمكن أن تتمّ عندئذ شاشة شاشة على عدّة sprints، دون كسر القائم. حين يصبح الـ store القديم أقلّيّاً، احذفه ويتلاشى الدين التقني تدريجيّاً.

أخطاء متكرّرة

الخطأ السبب الحلّ
NG0203 outside injection context inject() مُستدعى خارج injection context استعمل runInInjectionContext() أو parameter الافتراضيّ للـ factory
الحالة لا تُحدَّث في القالب نسيان الأقواس على الـ signal (store.query بدل store.query()) استدعِ دائماً signal كأنّها دالّة
حلقة لا نهائيّة من إعادة الاحتساب effect يُعدّل signal المصدر الخاصّ به استعمل untracked() حول التعديل
patchState لا يأخذ بعين الاعتبار update كائن مُعدَّل في مكانه بدلاً من مرجع جديد أرجع دائماً كائناً جديداً، لا تُعدّل في المكان
الـ store المحليّ يستمرّ بين المكوّنات خيار providedIn خاطئ أزل providedIn: 'root' لـ scope مكوّن

التكيّف مع السياقات المُقيَّدة

NgRx Signal Store يُقدّم منفعة صافية في حجم الـ bundle. الـ package التاريخيّ @ngrx/store كان يُضيف حوالي 25-30 Ko مضغوطة بـ gzip؛ @ngrx/signals يُضيف أقلّ من 10 Ko مضغوطة، وفقط الـ features المُستعمَلة تُدرَج (tree-shaking فعّال). لتطبيق mobile-first بميزانيّة bundle صارمة، عامل حاسم. الأداء التنفيذيّ يتبع نفس المنطق: انتشار تغيير يجتاز فقط المكوّنات التي تقرأ الـ signal المُعدَّل، حيث كان الـ store القديم يُطلق احتمالاً عدّة selectors ودورة كاملة من change detection على الشجرة.

أسئلة شائعة

هل يجب معرفة Redux لاستعمال NgRx Signal Store؟
لا. Signal Store يستوحي من Flux لكنّه لا يأخذ لا الـ actions ولا الـ reducers الصريحة. إذا كنت تعرف كتابة خدمة Angular مع signals، فأنت جاهز.

هل يحلّ Signal Store محلّ NgRx Store الكلاسيكي تماماً؟
للمشاريع الجديدة، نعم في 90% من الحالات. لتطبيقات تحتاج النظام البيئي لـ Redux DevTools، أو effects RxJS متطوّرة، أو journal actions كامل، NgRx Store الكلاسيكيّ يبقى مهمّاً. الهجرة التدريجيّة مُوصى بها.

كيف نُشارك Signal Store بين عدّة modules lazy-loaded؟
مع providedIn: 'root'، الـ store يُنشَأ مرّة واحدة ويُشارَك. إن أردت store لكلّ feature module، أزل providedIn: 'root' وأعلنه في providers المكوّن الجذر للـ feature.

هل plugin Events التجريبيّ في NgRx v20 يُستعمل في الإنتاج؟
ليس بعد. مُعَلَّم تجريبيّاً، API يمكن أن تتغيّر قبل الاستقرار. لمشروع في الإنتاج، احتفظ بمنهج الـ methods؛ استكشف Events على POC لتقييم القيمة المُضافة.

كيف نُديم Signal Store بين الجلسات؟
استعمل withHooks مع onInit للاستعادة من localStorage و effect يحفظ عند كلّ تغيّر. خيار آخر هو كتابة feature withPersistence(key) قابلة لإعادة الاستخدام عبر withFeature.

للاستزادة

المراجع

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité