Sinyal akan mengubah Angular menjadi lebih baik

Dari NgRx ComponentStore hingga SignalStore: kesimpulan utama dari proyek demo saya

Bagaimana mempersiapkan migrasi secara efektif

Saya yakin Signals in Angular akan mengubah cara kita membuat aplikasi Angular secara mendasar. Artikel ini adalah bagian pertama dari seri yang bertujuan untuk menunjukkan kepada Anda potensi fitur baru ini, dan pada saat yang sama membantu Anda bersiap menghadapi perubahan ini secara efektif: saat Signals berada dalam pratinjau pengembang dan penyimpanan berbasis sinyal NgRx hanyalah sebuah prototipe, Anda dapat mulai membuat dan memfaktorkan ulang komponen sedemikian rupa sehingga migrasi menjadi sangat lancar bagi Anda. Di bagian pertama ini, saya menunjukkan kepada Anda bagaimana saya menggunakan aplikasi demo untuk menunjukkan perbedaan antara ComponentStore dan model berbasis sinyal yang baru. Di bagian selanjutnya dari seri ini, saya akan menawarkan beberapa panduan tentang cara menavigasi perubahan ini. Jadi pertama-tama izinkan saya memperkenalkan Signalsdan NgRx SignalStore.

Angular Signals adalah model reaktivitas baru di Angular 16. Sinyalmembantu kami melacak perubahan status dalam aplikasi kami dan memicu rendering template yang dioptimalkan pembaruan. Jika Anda baru mengenal Signal, berikut beberapa artikel yang sangat direkomendasikan:

Tim NgRx dan Marko Stanimirović membuka RFC (Permintaan Komentar) baru untuk solusi manajemen negara berbasis sinyal, SignalStore. Ini memiliki pendekatan yang mirip dengan @ngrx/component-store. Implementasi awal dengan dokumentasi API tersedia di repo taman bermain NgRx SignalStore.

Seperti yang saya sebutkan, saya yakin bahwa Signal akan mengubah cara kami mengembangkan aplikasi Angular. Untuk mendapatkan lebih banyak pengetahuan tentang fitur baru ini dan dampaknya di masa depan, saya telah membuat dua versi komponen “daftar artikel”. Saya telah membuat yang berbasis ComponentStore terlebih dahulu, lalu memigrasikannya ke yang berbasis SignalStore. Dalam artikel ini, saya menjelaskan langkah-langkah penerapan dan perbedaan utama yang saya temukan, sehingga Anda dapat lebih memahami cara kerja SignalStores sebenarnya.

Kode sumber lengkap tersedia di sini:



Aplikasi ini menggunakan gaya dan backend yang dihosting publik dari proyek RealWorld.

Aplikasi ini memiliki fitur berikut:

  • Menusederhana untuk beralih antara daftar artikel berbasis ComponentStore dan SignalStore
  • Dua daftar artikel, salah satunya berbasis ComponentStore, yang lainnya berbasis SignalStore. Mereka menunjukkan penulis artikel, tanggal publikasi, jumlah, tag, dan prospek. Mereka memuat daftar artikel dari server, sehingga mengalami pemuatan dan status kesalahan
  • Komponen paginasi di bawah setiap daftar artikel. Pengguna juga dapat mengubah pagination berdasarkan parameter URL, misalnya: http://localhost:4200/article-list-component-store?selectedPage=3&pageSize=2. Jika pengguna mengubah parameter URL atau mengklik komponen penomoran halaman, daftar artikel akan dimuat ulang.

Arsitektur aplikasi

Saya menggunakan Angular v16 dengan komponen mandiri. Karena Signals belum berfungsi dalam aplikasi tanpa zona, saya menggunakan strategi deteksi perubahan OnPush dengan pipa async.

Aplikasi ini mem-bootstrap AppComponent dengan router-outlet dan dua item menu untuk dua versi daftar artikel:

  • ArticleListComponent_CS adalah versi daftar artikel berbasis ComponentStore. Itu terhubung dengan ArticleListComponentStore.
  • ArticleListComponent_SS adalah versi daftar artikel berbasis SignalStore. Itu terhubung dengan ArticleListSignalStore.

Kedua implementasi “daftar artikel” menggunakan penyimpanan tingkat komponen dan bergantung pada komponen UI berikut:

  • UiArticleListComponent menampilkan daftar artikel (UiArticleLisItemComponent)
  • UiPaginationComponent menangani penomoran halaman

Struktur direktorinya adalah sebagai berikut:

src/
|-- app/
|   |-- article-list-ngrx-component-store/ => ArticleListComponent_CS
|   |-- article-list-ngrx-signal-store/ => ArticleListComponent_SS
|   |-- models/
|   |-- services/
|   |-- ui-components/ 
|   |-- app.component.ts
|   |-- app.routes.ts
|-- libs/signal-store/

Komponen daftar artikel

Kode kelas kedua komponen daftar artikel hampir sama:

  • kami menyuntikkan router dan toko,
  • kami memperbarui parameter penomoran halaman di penyimpanan setelah komponen dibuat. Kami juga memperbarui parameter jika parameter pada URL berubah

Satu-satunya perbedaan di antara keduanya adalah kelas penyimpanan yang disuntikkan: ArticleListComponentStore dan ArticleListSignalStore:

export class ArticleListComponent_CS {
  readonly store = inject(ArticleListComponentStore);
  readonly route = inject(ActivatedRoute);

  constructor(
  ) {
    this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(
      routeParams => {
      this.store.setPaginationSettings(routeParams);
      this.store.loadArticles();
    });
  }
}
export class ArticleListComponent_SS {
  readonly store = inject(ArticleListSignalStore);
  readonly route = inject(ActivatedRoute);

  constructor(
  ) {
    this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(
      routeParams => {
      this.store.setPaginationSettings(routeParams);
      this.store.loadArticles();
    });
  }
}

Templat komponennya juga serupa. Perbedaan mendasar adalah cara kita membaca data dari penyimpanan:

  • kami menggunakan pipa async untuk membaca dari pemilihdari ComponentStore, dan
  • kita cukup mendapatkan nilai sinyaldi SignalStore
@Component({
  selector: ‘app-article-list-cs’,
  // ...
  providers: [ArticleListComponentStore],
  template: `
<ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHING’">
  Loading...
</ng-container>
<ng-container *ngIf="store.httpRequestState$ | async | httpRequestStateErrorPipe as errorMessage">
  {{ errorMessage }}
</ng-container>
<ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHED’">
  <ng-container *ngIf="store.articles$ | async as articles">
    <app-ui-article-list [articles]="articles"/>
  </ng-container>
  <ng-container *ngIf="store.pagination$ | async as pagination">
    <app-ui-pagination
      [selectedPage]="pagination.selectedPage"
      [totalPages]="pagination.totalPages"
      (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" />
  </ng-container>
</ng-container>
  `
})
@Component({
  selector: ‘app-article-list-ss’,
  // ...
  providers: [ArticleListSignalStore],
  template: `
<ng-container *ngIf="store.httpRequestState() === ‘FETCHING’">
  Loading...
</ng-container>
<ng-container *ngIf="store.httpRequestState() | httpRequestStateErrorPipe as errorMessage">
  {{ errorMessage }}
</ng-container>
<ng-container *ngIf="store.httpRequestState() === ‘FETCHED’">
  <ng-container *ngIf="store.articles() as articles">
    <app-ui-article-list [articles]="articles"/>
  </ng-container>
  <ng-container *ngIf="store.pagination() as pagination">
    <app-ui-pagination
      [selectedPage]="pagination.selectedPage()"
      [totalPages]="pagination.totalPages()"
      (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" />
  </ng-container>
</ng-container>
  `
})

Negara

Saya menerapkan struktur data yang tidak dapat diubah yang sama untuk menyimpan status di kedua penyimpanan (HttpRequestState dan Articles juga merupakan tipe yang tidak dapat diubah):

export type ArticleListState = {
  readonly selectedPage: number,
  readonly pageSize: number,

  readonly httpRequestState: HttpRequestState,

  readonly articles: Articles,
  readonly articlesCount: number,
}

Properti selectedPage menentukan halaman yang terlihat saat ini, properti pageSize menentukan berapa banyak artikel yang terlihat. Pengguna dapat mengubah nilai-nilai ini dengan menggunakan komponen penomoran halaman atau dengan menerapkan parameter URL.

Properti httpRequestState berisi status permintaan daftar artikel:

export type HttpRequestState = DeepReadonly<
  'EMPTY' | 'FETCHING' | 'FETCHED' |
  { errorMessage: string }
  >;

Awalnya, nilainya adalah `EMPTY`. Kami mengubahnya menjadi `FETCHING` tepat sebelum kami mengirim permintaan ke server. Saat respons server tiba, kami menetapkan nilainya menjadi `FETCHED`. Jika server mengirimkan respons kesalahan atau ada kesalahan selama permintaan, kami menetapkan status permintaan ke objek { errorMessage: string } dengan pesan kesalahan.

Respons server berisi jumlah total artikel dan artikel itu sendiri, kami menyimpannya di properti articlesCount dan articles.

Setelah kita membuat komponen daftar artikel, penyimpanannya memiliki status awal:

export const initialArticleListState: ArticleListState = {
  selectedPage: 0,
  pageSize: 3,

  httpRequestState: ‘EMPTY’,

  articles: [],
  articlesCount: 0
}

Toko

Saya memperluas ArticleListComponentStore dari ComponentStore:

@Injectable()
export class ArticleListComponentStore extends ComponentStore<ArticleListState> {
  readonly selectedPage$: Observable<number> = /* ... */;
  readonly pageSize$: Observable<number> = /* ... */;
  readonly httpRequestState$: Observable<HttpRequestState> = /* ... */;
  readonly articles$: Observable<DeepReadonly<Articles>> = /* ... */;
  readonly articlesCount$: Observable<number> = /* ... */;

  readonly totalPages$: Observable<number> = /* ... */;

  readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = /* ... */;

  readonly articlesService = inject(ArticlesService);

  constructor(
  ) {
    super(initialArticleListState);
  }

  setPaginationSettings = this.updater(
    (state, s: RouteParamsPaginatonState) => /* ... */);

  readonly loadArticles = this.effect<void>(/* ... */);

  setRequestStateLoading = this.updater(
    (state) => /* ... */);

  setRequestStateSuccess = this.updater(
    (state, params: ArticlesResponseType) => /* ... */);

  setRequestStateError = this.updater(
    (state, error: string): => /* ... */);

  setSelectedPage = this.updater(
    (state, selectedPage: number) => /* ... */);
}

Saya membuat `ArticleListSignalStore` dengan fungsi `signalStore()`. Ia menerima serangkaian fitur toko, saya akan menjelaskannya lebih detail:

export const ArticleListSignalStore = signalStore(
  { debugId: ‘ArticleListSignalStore’ },
  withState<ArticleListState>(initialArticleListState),
  withComputed(({ articlesCount, pageSize }) => ({ /* ... */ })),
  withComputed(({ selectedPage, totalPages }) => ({ /* ... */ })),
  withUpdaters(({ update }) => ({
    setPaginationSettings: (s: RouteParamsPaginatonState) =>  /* ... */,
    setRequestStateLoading: () => /* ... */ ,
    setRequestStateSuccess: => /* ... */ ,
    setRequestStateError: (error: string) => /* ... */ ,
    setSelectedPage: (selectedPage: number) =>  /* ... */,
  withEffects(
    ( {
      selectedPage, pageSize,
      setRequestStateLoading, setRequestStateSuccess, setRequestStateError
      },
    ) => {
      const articlesService = inject(ArticlesService)
      // ...
    }
  )
);

Penyeleksi

ArticleListComponentStore menyimpan negara bagian dalam store$ subjeknya. Subyek ini mengeluarkan nilai pada setiap perubahan state. Untuk mengamati modifikasi properti negara satu per satu, kami membuat pemilih terpisah untuk masing-masing properti berikut:

readonly selectedPage$: Observable<number> = 
    this.select(state => state.selectedPage);
  readonly pageSize$: Observable<number> = 
    this.select(state => state.pageSize);
  readonly httpRequestState$: Observable<HttpRequestState> = 
    this.select(state => state.httpRequestState);
  readonly articles$: Observable<DeepReadonly<Articles>> = 
    this.select(state => state.articles);
  readonly articlesCount$: Observable<number> = 
    this.select(state => state.articlesCount);

SignalStore secara otomatis membuat signal terpisah untuk semua properti root negara. Kami menyebutnya sebagai negara parsial. Kita dapat mengakses sebagian negara bagian ini dengan:

  • ArticleListSignalStore.selectedPage()
  • ArticleListSignalStore.pageSize()
  • ArticleListSignalStore.httpRequestState()
  • ArticleListSignalStore.articles() dan
  • ArticleListSignalStore.articlesCount()

Saya membuat pemilih gabungan tambahan di ArticleListComponentStore untuk menghitung jumlah halaman:

readonly totalPages$: Observable<number> = this.select(
    this.articlesCount$, this.pageSize$,
    (articlesCount, pageSize) => Math.ceil(articlesCount / pageSize));

Untuk melakukan hal yang sama di ArticleListSignalStore, saya menggunakan fungsi withComputed(). Saya memberikan sinyal articlesCount dan pageSize sebagai parameter fungsi, dan menghitung jumlah total halaman:

withComputed(({ articlesCount, pageSize }) => ({
    totalPages: computed(() => Math.ceil(articlesCount() / pageSize())),
  })),

Kita juga perlu menambahkan pemilih “model tampilan” ke komponen penomoran halaman. Ini adalah kode untuk selector di ArticleListComponentStore:

readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = this.select(
    this.selectedPage$,
    this.totalPages$,
    (selectedPage, totalPages) => ({ selectedPage, totalPages })
  );

Dan ini juga merupakan pemilih yang sama di ArticleListSignalStore:

withComputed(({ selectedPage, totalPages }) => ({
    pagination: computed(() => ({ selectedPage, totalPages })),
  })),

Pembaru

Di dalam updaters dari ComponentStore, kami selalu membuat objek status baru yang tidak dapat diubah dengan nilai yang diperbarui dan mengembalikannya. Objek status yang dikembalikan berisi semua properti dari status, baik yang diperbarui maupun yang tidak diubah.

Misalnya, ini adalah cara kami menangani respons server:

setRequestStateSuccess = this.updater((state, params: ArticlesResponseType): ArticleListState => {
    return {
      ...state,
      httpRequestState: ‘FETCHED’,
      articles: params.articles,
      articlesCount: params.articlesCount
    }
  });

Parameter params berisi nilai articles dan articlesCount dari respon server:

export type ArticlesResponseType = {
  articles: Articles,
  articlesCount: number
}

Di ArticleListSignalStore, kita membuat updaters dengan fungsi withUpdaters(). Dalam updaters ini, kami membuat objek baru yang tidak dapat diubah hanya dari properti yang diperbarui, jadi tidak ada …state di sini. SignalStore memperbarui sebagian status dengan menggunakan nilai properti yang dikembalikan berikut:

withUpdaters(({ update }) => ({
    setPaginationSettings: (s: RouteParamsPaginatonState) => update(() => ({
    // ...
    setRequestStateSuccess: (params: ArticlesResponseType) => update(() => ({
      httpRequestState: ‘FETCHED’,
      articles: params.articles,
      articlesCount: params.articlesCount
    }))
    // ...
  }))

Efek

Toko memiliki satu effect yang mengambil daftar artikel dari server. Ini adalah effect dari ArticleListComponentStore:

readonly loadArticles = this.effect<void>((trigger$: Observable<void>) => {
    return trigger$.pipe(
      withLatestFrom(this.selectedPage$, this.pageSize$),
      tap(() => this.setRequestStateLoading()),
      switchMap(([, selectedPage, pageSize]) => {
        return this.articlesService.getArticles({
          limit: pageSize,
          offset: selectedPage * pageSize
        }).pipe(
          tapResponse(
            (response) => {
              this.setRequestStateSuccess(response);
            },
            (errorResponse: HttpErrorResponse) => {
              this.setRequestStateError(‘Request error’);
            }
          ),
        );
      }),
    );
  });

Di SignalStore, kami mengimplementasikan efek dengan fungsi withEffects(). SignalStores mendukung dua jenis efek berbeda: efek berbasis RxJs dan efek berbasis Promise. Efek berbasis RxJs terlihat sangat mirip dengan efek yang kami gunakan di ComponentStore:

withEffects(
    ( {
      selectedPage, pageSize,
      setRequestStateLoading, setRequestStateSuccess, setRequestStateError
      },
    ) => {
      const articlesService = inject(ArticlesService)
      return {
        loadArticles: rxEffect<void>(
          pipe(
            tap(() => setRequestStateLoading()),
            switchMap(() => articlesService.getArticles({
              limit: pageSize(),
              offset: selectedPage() * pageSize()
            })),
            tapResponse(
              (response) => {
                setRequestStateSuccess(response);
              },
              (errorResponse: HttpErrorResponse) => {
                setRequestStateError(‘Request error’);
              }
            )
          )
        )
      }
    }
  )

Efek berbasis Promise berguna ketika Promise memiliki fungsionalitas yang memadai dan kita tidak memerlukan kekuatan RxJs. Dalam hal mengambil data dari server, ada kelemahannya: tidak mendukung logika pembatalan:

withEffects(
    ( {
      selectedPage, pageSize,
      setRequestStateLoading, setRequestStateSuccess, setRequestStateError
      },
    ) => {
      const articlesService = inject(ArticlesService)
      return {
        async loadArticles() {
          setRequestStateLoading();
          try {
            const response = await lastValueFrom(articlesService.getArticles({
              limit: pageSize(),
              offset: selectedPage() * pageSize()
            }));
            setRequestStateSuccess(response);
          }
          catch(e) {
            setRequestStateError(‘Request error’);
          }
        }
      }
    }
  )

Ringkasan

Singkatnya, perbedaan utama antara ComponentStore dan SignalStore adalah sebagai berikut:

  • ComponentStore memiliki statusnya di subjek state$. SignalStore memiliki sinyal terpisah untuk semua properti root negara (keadaan parsial)
  • Di SignalStore, kita tidak memerlukan penyeleksi untuk mengakses properti tingkat akar negara bagian. Ia menyimpannya dalam sinyal terpisah, sehingga dapat diakses secara langsung.
  • Baik ComponentStore dan SignalStore mendukung efek berbasis RxJs, selain itu SignalStore juga mendukung efek berbasis Promise.

Meskipun implementasi SignalStore saat ini hanyalah sebuah prototipe, dan mungkin ada perubahan API di masa mendatang, saya sangat menikmati bekerja dengannya. API-nya fleksibel dan mudah dipahami, karena mengikuti konsep dasar ComponentStore, tetapi dengan cara yang lebih maju.

Untuk mempermudah men-debug perubahan status, updaters dan effects, saya menambal kode SignalStore asli dengan beberapa kode debug dari proyek ngx-ngrx-component-store-debug-tools saya.

Pertanyaan utama yang saya miliki sekarang adalah bagaimana membuat ComponentStores sedemikian rupa sehingga ketika SignalStore siap produksi dirilis, proses migrasi dapat dikelola dengan mudah.

Di bagian selanjutnya dari seri artikel saya, saya akan menjelaskan beberapa pedoman yang akan membantu kita mencapai tujuan ini. Selain itu, saya akan memeriksa beberapa skenario kompleks dan membandingkan kedua pendekatan ini, misalnya cara kerja pembatalan permintaan HTTP di SignalStore dan ComponentStore.

Terima kasih telah membaca, saya harap artikel saya bermanfaat bagi Anda, beri tahu saya jika Anda memiliki masukan!

👨‍💻Tentang penulis

Nama saya Gergely Szerovay, saya bekerja sebagai pemimpin bab pengembangan frontend. Mengajar (dan belajar) Angular adalah salah satu minat saya. Saya mengonsumsi konten yang terkait dengan Angular setiap hari — artikel, podcast, pembicaraan konferensi, apa saja.

Saya membuat Buletin Angular Addict sehingga saya dapat mengirimi Anda sumber daya terbaik yang saya temukan setiap bulan. Baik Anda seorang Angular Addict berpengalaman atau pemula, saya siap membantu Anda.

Di samping buletin, saya juga memiliki publikasi berjudul — Anda dapat menebaknya — Angular Addicts. Ini adalah kumpulan sumber daya yang menurut saya paling informatif dan menarik. Beri tahu saya jika Anda ingin dimasukkan sebagai penulis.

Mari belajar Angular bersama! Berlangganan di sini 🔥

Ikuti saya di “Medium”, “Twitter” atau “LinkedIn” untuk mempelajari lebih lanjut tentang Angular!