From 882bf93bd3caa0f1c8f963147dcd3719a88ea874 Mon Sep 17 00:00:00 2001 From: Hamza Hanfi Date: Thu, 5 Feb 2026 23:01:25 +0100 Subject: [PATCH 1/2] feat: install and setup of singalStore --- .../src/app/list/photo-signal.store.ts | 68 +++++++++++++++++++ .../src/app/list/photos.component.ts | 61 ++++++++--------- package-lock.json | 19 ++++++ package.json | 1 + 4 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts diff --git a/apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts b/apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts new file mode 100644 index 000000000..8bfca004b --- /dev/null +++ b/apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts @@ -0,0 +1,68 @@ +import { computed, effect } from '@angular/core'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { Photo } from '../photo.model'; + +export interface PhotoState { + photos: Photo[]; + search: string; + page: number; + pages: number; + loading: boolean; + error: unknown; +} + +const initialState: PhotoState = { + photos: [], + search: '', + page: 1, + pages: 1, + loading: false, + error: '', +}; + +const PHOTO_STATE_KEY = 'photo_search'; + +export const PhotoSignalStore = signalStore( + withState(initialState), + withComputed((store) => ({ + endOfPage: computed(() => store.page() === store.pages()), + })), + withMethods((store) => { + return { + nextPage() { + patchState(store, { + page: store.page() + 1, + }); + }, + previousPage() { + patchState(store, { + page: store.page() - 1, + }); + }, + setSearch(search: string) { + patchState(store, { search, page: 1 }); + }, + }; + }), + withHooks({ + onInit(store) { + const savedJSONState = localStorage.getItem(PHOTO_STATE_KEY); + savedJSONState === null + ? patchState(store) + : patchState(store, { + search: JSON.parse(savedJSONState).search, + page: JSON.parse(savedJSONState).page, + }); + effect(() => { + console.log('search/page', store.search(), store.page()); + }); + }, + }), +); diff --git a/apps/angular/interop-rxjs-signal/src/app/list/photos.component.ts b/apps/angular/interop-rxjs-signal/src/app/list/photos.component.ts index 29dc0c3f5..e5450e1d1 100644 --- a/apps/angular/interop-rxjs-signal/src/app/list/photos.component.ts +++ b/apps/angular/interop-rxjs-signal/src/app/list/photos.component.ts @@ -1,14 +1,15 @@ import { NgFor, NgIf } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { RouterLinkWithHref } from '@angular/router'; -import { LetDirective } from '@ngrx/component'; import { provideComponentStore } from '@ngrx/component-store'; -import { debounceTime, distinctUntilChanged, skipWhile, tap } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter } from 'rxjs'; import { Photo } from '../photo.model'; +import { PhotoSignalStore } from './photo-signal.store'; import { PhotoStore } from './photos.store'; @Component({ @@ -21,7 +22,6 @@ import { PhotoStore } from './photos.store'; NgIf, NgFor, MatInputModule, - LetDirective, RouterLinkWithHref, ], template: ` @@ -36,33 +36,36 @@ import { PhotoStore } from './photos.store'; placeholder="find a photo" /> - +
- Page :{{ vm.page }} / {{ vm.pages }} + Page :{{ signalStore.page() }} / {{ signalStore.pages() }}
`, - providers: [provideComponentStore(PhotoStore)], + providers: [provideComponentStore(PhotoStore), PhotoSignalStore], host: { class: 'p-5 block', }, }) export default class PhotosComponent implements OnInit { - store = inject(PhotoStore); - readonly vm$ = this.store.vm$.pipe( - tap(({ search }) => { - if (!this.formInit) { - this.search.setValue(search); - this.formInit = true; - } - }), - ); - - private formInit = false; + readonly signalStore = inject(PhotoSignalStore); + private readonly destroyRef = inject(DestroyRef); search = new FormControl(); ngOnInit(): void { - this.store.search( - this.search.valueChanges.pipe( - skipWhile(() => !this.formInit), + this.search.valueChanges + .pipe( debounceTime(300), distinctUntilChanged(), - ), - ); + filter((value) => value.length >= 3), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((searchValue) => this.signalStore.setSearch(searchValue)); } trackById(index: number, photo: Photo) { diff --git a/package-lock.json b/package-lock.json index 795271824..a3bcc0dd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@ngrx/effects": "17.0.1", "@ngrx/entity": "17.0.1", "@ngrx/router-store": "17.0.1", + "@ngrx/signals": "^17.2.0", "@ngrx/store": "17.0.1", "@nx/angular": "17.2.8", "@rx-angular/cdk": "^17.0.0", @@ -5590,6 +5591,24 @@ "integrity": "sha512-yOQxy6PWKbxS2JhE54Li5qAJVruZGkUrsVCcx6pFKssKMGBG1R0EHBZ0BAmFtkiHvyHz3P/RUiaNf+UbWPPgIw==", "dev": true }, + "node_modules/@ngrx/signals": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-17.2.0.tgz", + "integrity": "sha512-tkkxifeOVPOhpTqbHyK1WOx4qz49HLR/h0vhaa/MRGRIZoOR/6gR4KB3hbC8FD3FdnuNqOgOZ2lGsTfWPB/6BQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^17.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/@ngrx/store": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-17.0.1.tgz", diff --git a/package.json b/package.json index 3507f1bb6..c8583d63f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@ngrx/effects": "17.0.1", "@ngrx/entity": "17.0.1", "@ngrx/router-store": "17.0.1", + "@ngrx/signals": "^17.2.0", "@ngrx/store": "17.0.1", "@nx/angular": "17.2.8", "@rx-angular/cdk": "^17.0.0", From c8e68762200ca26a90c3670e4af39585e5d48f96 Mon Sep 17 00:00:00 2001 From: Hamza hanfi Date: Mon, 9 Feb 2026 00:24:25 +0100 Subject: [PATCH 2/2] feat: connect the store --- .../src/app/list/photo-signal.store.ts | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts b/apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts index 8bfca004b..2041fc4a2 100644 --- a/apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts +++ b/apps/angular/interop-rxjs-signal/src/app/list/photo-signal.store.ts @@ -1,4 +1,4 @@ -import { computed, effect } from '@angular/core'; +import { computed, effect, inject } from '@angular/core'; import { patchState, signalStore, @@ -7,7 +7,9 @@ import { withMethods, withState, } from '@ngrx/signals'; +import { EMPTY, Subject, catchError, mergeMap, tap } from 'rxjs'; import { Photo } from '../photo.model'; +import { PhotoService } from '../photos.service'; export interface PhotoState { photos: Photo[]; @@ -60,9 +62,55 @@ export const PhotoSignalStore = signalStore( search: JSON.parse(savedJSONState).search, page: JSON.parse(savedJSONState).page, }); - effect(() => { - console.log('search/page', store.search(), store.page()); + const photoService = inject(PhotoService); + const querySearch = computed<{ search: string; page: number }>(() => ({ + search: store.search(), + page: store.page(), + })); + const querySearchSubject$ = new Subject<{ + search: string; + page: number; + }>(); + + const sub = querySearchSubject$ + .pipe( + tap(() => patchState(store, { loading: true, error: '' })), + mergeMap(({ search, page }) => { + if (!search) { + patchState(store, { + photos: [], + pages: 1, + loading: false, + }); + return EMPTY; + } + return photoService.searchPublicPhotos(search, page).pipe( + catchError((error) => { + patchState(store, { loading: false, error: 'error' }); + return EMPTY; + }), + ); + }), + ) + .subscribe(({ photos }) => { + patchState(store, { + pages: photos.pages, + photos: photos.photo, + loading: false, + }); + localStorage.setItem( + PHOTO_STATE_KEY, + JSON.stringify({ search: store.search(), page: store.page() }), + ); + }); + + const ref = effect(() => querySearchSubject$.next(querySearch()), { + allowSignalWrites: true, }); + return () => { + ref.destroy(); + sub.unsubscribe(); + }; }, }), );