diff --git a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts
index 7ba115027..51c142818 100644
--- a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts
+++ b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts
@@ -1,30 +1,21 @@
-import { AsyncPipe } from '@angular/common';
-import { Component, inject, OnInit } from '@angular/core';
-import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { Component, inject } from '@angular/core';
+import { FormsModule } 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 { provideComponentStore } from '@ngrx/component-store';
-import {
- debounceTime,
- distinctUntilChanged,
- Observable,
- skipWhile,
- tap,
-} from 'rxjs';
+
import { Photo } from '../photo.model';
-import { PhotoStore } from './photos.store';
+import { PhotosStore } from './photos.store';
@Component({
selector: 'app-photos',
imports: [
- ReactiveFormsModule,
+ FormsModule,
MatFormFieldModule,
MatProgressBarModule,
MatInputModule,
RouterLinkWithHref,
- AsyncPipe,
],
template: `
Photos
@@ -34,93 +25,61 @@ import { PhotoStore } from './photos.store';
- @let vm = vm$ | async;
-
-
-
-
- Page :{{ vm.page }} / {{ vm.pages }}
-
- @if (vm.loading) {
-
- }
- @if (vm.photos && vm.photos.length > 0) {
-
- @for (
- photo of vm.photos;
- track photo.id;
- let i = $index
- ) {
- -
-
-
-
-
- }
-
- } @else {
- No Photos found. Type a search word.
- }
-
+
+
+
+
+ Page :{{ store.page() }} / {{ store.pages() }}
+ @if (store.loading()) {
+
+ }
+ @let photos = store.data();
+ @if (photos && photos.length > 0) {
+
+ @for (photo of photos; track photo.id; let i = $index) {
+ -
+
+
+
+
+ }
+
+ } @else {
+ No Photos found. Type a search word.
+ }
+
+
`,
- providers: [provideComponentStore(PhotoStore)],
+ providers: [PhotosStore],
host: {
class: 'p-5 block',
},
})
-export default class PhotosComponent implements OnInit {
- store = inject(PhotoStore);
- readonly vm$: Observable<{
- photos: Photo[];
- search: string;
- page: number;
- pages: number;
- endOfPage: boolean;
- loading: boolean;
- error: unknown;
- }> = this.store.vm$.pipe(
- tap(({ search }) => {
- if (!this.formInit) {
- this.search.setValue(search);
- this.formInit = true;
- }
- }),
- );
-
- private formInit = false;
- search = new FormControl();
-
- ngOnInit(): void {
- this.store.search(
- this.search.valueChanges.pipe(
- skipWhile(() => !this.formInit),
- debounceTime(300),
- distinctUntilChanged(),
- ),
- );
- }
+export default class PhotosComponent {
+ readonly store = inject(PhotosStore);
encode(photo: Photo) {
return encodeURIComponent(JSON.stringify(photo));
diff --git a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts
index f1315e87e..ef37876b8 100644
--- a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts
+++ b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts
@@ -1,135 +1,41 @@
-import { inject, Injectable } from '@angular/core';
-import {
- ComponentStore,
- OnStateInit,
- OnStoreInit,
-} from '@ngrx/component-store';
-import { tapResponse } from '@ngrx/operators';
-import { pipe } from 'rxjs';
-import { filter, mergeMap, tap } from 'rxjs/operators';
-import { Photo } from '../photo.model';
+import { withStorageSync } from '@angular-architects/ngrx-toolkit';
+import { inject } from '@angular/core';
+import { signalStore, withComputed, withHooks } from '@ngrx/signals';
import { PhotoService } from '../photos.service';
-
-const PHOTO_STATE_KEY = 'photo_search';
-
-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: '',
-};
-
-@Injectable()
-export class PhotoStore
- extends ComponentStore
- implements OnStoreInit, OnStateInit
-{
- private photoService = inject(PhotoService);
-
- private readonly photos$ = this.select((s) => s.photos);
- private readonly search$ = this.select((s) => s.search);
- private readonly page$ = this.select((s) => s.page);
- private readonly pages$ = this.select((s) => s.pages);
- private readonly error$ = this.select((s) => s.error);
- private readonly loading$ = this.select((s) => s.loading);
-
- private readonly endOfPage$ = this.select(
- this.page$,
- this.pages$,
- (page, pages) => page === pages,
- );
-
- readonly vm$ = this.select(
- {
- photos: this.photos$,
- search: this.search$,
- page: this.page$,
- pages: this.pages$,
- endOfPage: this.endOfPage$,
- loading: this.loading$,
- error: this.error$,
+import { withRemoteResource } from '../store-feature/with-remote-resource.feature';
+import { withRequestStatus } from '../store-feature/with-request-status.feature';
+import { withSearchAndPaging } from '../store-feature/with-search-and-paging.feature';
+
+export const PHOTO_STATE_KEY = 'photo_search';
+
+export const PhotosStore = signalStore(
+ withRequestStatus(),
+ withSearchAndPaging(),
+ withStorageSync({
+ key: PHOTO_STATE_KEY,
+ autoSync: false,
+ select: ({ page, search }) => ({ page, search }),
+ }),
+ withRemoteResource({
+ syncToStorage: true,
+ loaderFnFactory: () => {
+ const service = inject(PhotoService);
+ return service.searchPublicPhotos.bind(service);
},
- { debounce: true },
- );
-
- ngrxOnStoreInit() {
- const savedJSONState = localStorage.getItem(PHOTO_STATE_KEY);
- if (savedJSONState === null) {
- this.setState(initialState);
- } else {
- const savedState = JSON.parse(savedJSONState);
- this.setState({
- ...initialState,
- search: savedState.search,
- page: savedState.page,
- });
- }
- }
-
- ngrxOnStateInit() {
- this.searchPhotos(
- this.select({
- search: this.search$,
- page: this.page$,
- }),
- );
- }
-
- readonly search = this.updater(
- (state, search: string): PhotoState => ({
- ...state,
- search,
- page: 1,
- }),
- );
-
- readonly nextPage = this.updater(
- (state): PhotoState => ({
- ...state,
- page: state.page + 1,
- }),
- );
-
- readonly previousPage = this.updater(
- (state): PhotoState => ({
- ...state,
- page: state.page - 1,
- }),
- );
-
- readonly searchPhotos = this.effect<{ search: string; page: number }>(
- pipe(
- filter(({ search }) => search.length >= 3),
- tap(() => this.patchState({ loading: true, error: '' })),
- mergeMap(({ search, page }) =>
- this.photoService.searchPublicPhotos(search, page).pipe(
- tapResponse(
- ({ photos: { photo, pages } }) => {
- this.patchState({
- loading: false,
- photos: photo,
- pages,
- });
- localStorage.setItem(
- PHOTO_STATE_KEY,
- JSON.stringify({ search, page }),
- );
- },
- (error: unknown) => this.patchState({ error, loading: false }),
- ),
- ),
- ),
- ),
- );
-}
+ }),
+ withComputed(({ page, pages }) => ({
+ endOfPage: () => page() === pages(),
+ })),
+ withHooks((store) => {
+ return {
+ onInit() {
+ store.readFromStorage();
+
+ store.loadResource(() => ({
+ search: store.search(),
+ page: store.page(),
+ }));
+ },
+ };
+ }),
+);
diff --git a/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts b/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts
index c540118f6..021d62548 100644
--- a/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts
+++ b/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts
@@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
-import { Injectable, inject } from '@angular/core';
-import { Observable } from 'rxjs';
+import { inject, Injectable } from '@angular/core';
+import { map, Observable } from 'rxjs';
import { Photo } from './photo.model';
export interface FlickrAPIResponse {
@@ -21,10 +21,9 @@ export class PhotoService {
public searchPublicPhotos(
searchTerm: string,
page: number,
- ): Observable {
- return this.http.get(
- 'https://www.flickr.com/services/rest/',
- {
+ ): Observable<{ data: Photo[]; pages: number }> {
+ return this.http
+ .get('https://www.flickr.com/services/rest/', {
params: {
tags: searchTerm,
method: 'flickr.photos.search',
@@ -37,7 +36,11 @@ export class PhotoService {
extras: 'tags,date_taken,owner_name,url_q,url_m',
api_key: 'c3050d39a5bb308d9921bef0e15c437d',
},
- },
- );
+ })
+ .pipe(
+ map(({ photos: { pages, photo } }) => {
+ return { pages, data: photo };
+ }),
+ );
}
}
diff --git a/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-remote-resource.feature.ts b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-remote-resource.feature.ts
new file mode 100644
index 000000000..569d27f90
--- /dev/null
+++ b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-remote-resource.feature.ts
@@ -0,0 +1,92 @@
+import { tapResponse } from '@ngrx/operators';
+import {
+ patchState,
+ signalStoreFeature,
+ type,
+ withMethods,
+ withState,
+} from '@ngrx/signals';
+import { rxMethod } from '@ngrx/signals/rxjs-interop';
+import {
+ debounceTime,
+ distinctUntilChanged,
+ filter,
+ Observable,
+ pipe,
+ switchMap,
+ tap,
+} from 'rxjs';
+import {
+ RequestStatusState,
+ setError,
+ setLoading,
+} from './with-request-status.feature';
+
+export type LoaderFn = (
+ search: string,
+ page: number,
+) => Observable<{ data: TData[]; pages: number }>;
+
+export interface RemoteResourceState {
+ data: TData;
+ pages: number;
+}
+
+export function withRemoteResource({
+ syncToStorage = true,
+ loaderFnFactory,
+}: {
+ syncToStorage?: boolean;
+ loaderFnFactory: () => LoaderFn;
+}) {
+ return signalStoreFeature(
+ {
+ state: type(),
+ methods: type<{ writeToStorage?: () => void }>(),
+ },
+ withState({
+ data: [] as TData[],
+ pages: 1,
+ }),
+ withMethods((store) => {
+ const loaderFn = loaderFnFactory();
+
+ return {
+ loadResource: rxMethod<{ search: string; page: number }>(
+ pipe(
+ filter(({ search }) => search.length >= 3),
+ debounceTime(300),
+ distinctUntilChanged(),
+ tap(() => patchState(store, setLoading(true))),
+ switchMap(({ search, page }) => {
+ return loaderFn(search, page).pipe(
+ tapResponse({
+ next({ data, pages }) {
+ patchState(
+ store,
+ {
+ data,
+ pages,
+ },
+ setLoading(false),
+ );
+
+ if (
+ syncToStorage &&
+ typeof store.writeToStorage === 'function'
+ ) {
+ store?.writeToStorage();
+ }
+ },
+ error(error) {
+ patchState(store, setError(error));
+ },
+ }),
+ );
+ }),
+ ),
+ ),
+ };
+ }),
+ );
+}
diff --git a/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-request-status.feature.ts b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-request-status.feature.ts
new file mode 100644
index 000000000..1f8b41b55
--- /dev/null
+++ b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-request-status.feature.ts
@@ -0,0 +1,23 @@
+import { signalStoreFeature, withState } from '@ngrx/signals';
+
+export interface RequestStatusState {
+ loading: boolean;
+ error: unknown;
+}
+
+const initialState: RequestStatusState = {
+ loading: false,
+ error: '',
+};
+
+export function withRequestStatus() {
+ return signalStoreFeature(withState(initialState));
+}
+
+export function setLoading(loading: boolean): RequestStatusState {
+ return { loading, error: '' };
+}
+
+export function setError(error: unknown): RequestStatusState {
+ return { error, loading: false };
+}
diff --git a/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-search-and-paging.feature.ts b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-search-and-paging.feature.ts
new file mode 100644
index 000000000..2faea92da
--- /dev/null
+++ b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-search-and-paging.feature.ts
@@ -0,0 +1,33 @@
+import {
+ patchState,
+ signalStoreFeature,
+ withMethods,
+ withState,
+} from '@ngrx/signals';
+
+export interface SearchAndPagingState {
+ search: string;
+ page: number;
+}
+
+export function withSearchAndPaging() {
+ return signalStoreFeature(
+ withState({
+ search: '',
+ page: 1,
+ }),
+ withMethods((store) => {
+ return {
+ setSearch: (search: string) => {
+ patchState(store, { search, page: 1 });
+ },
+ nextPage: () => {
+ patchState(store, ({ page }) => ({ page: page + 1 }));
+ },
+ previousPage: () => {
+ patchState(store, ({ page }) => ({ page: page - 1 }));
+ },
+ };
+ }),
+ );
+}
diff --git a/package.json b/package.json
index 30b08fad3..a4bae1fca 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0",
+ "@angular-architects/ngrx-toolkit": "^21.0.1",
"@angular/animations": "21.2.2",
"@angular/cdk": "21.2.1",
"@angular/common": "21.2.2",
@@ -35,6 +36,7 @@
"@ngneat/falso": "7.2.0",
"@ngrx/component-store": "21.0.0",
"@ngrx/operators": "21.0.0",
+ "@ngrx/signals": "^21.0.1",
"@nx/angular": "22.5.4",
"@swc/helpers": "0.5.19",
"@tanstack/angular-query-experimental": "5.90.16",
@@ -50,7 +52,6 @@
"@angular-devkit/build-angular": "21.2.1",
"@angular-devkit/core": "21.2.1",
"@angular-devkit/schematics": "21.2.1",
- "angular-eslint": "21.3.0",
"@angular/build": "21.2.1",
"@angular/cli": "21.2.1",
"@angular/compiler-cli": "21.2.2",
@@ -89,6 +90,7 @@
"@typescript-eslint/utils": "^8.40.0",
"@vitest/browser-playwright": "4.1.0-beta.4",
"all-contributors-cli": "^6.26.1",
+ "angular-eslint": "21.3.0",
"autoprefixer": "^10.4.0",
"cypress": "15.8.1",
"eslint": "^9.8.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd1162dad..64025e6f7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
'@actions/github':
specifier: ^6.0.0
version: 6.0.1
+ '@angular-architects/ngrx-toolkit':
+ specifier: ^21.0.1
+ version: 21.0.1(@angular/common@21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(@ngrx/signals@21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(rxjs@7.8.1)
'@angular/animations':
specifier: 21.2.2
version: 21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))
@@ -59,6 +62,9 @@ importers:
'@ngrx/operators':
specifier: 21.0.0
version: 21.0.0(rxjs@7.8.1)
+ '@ngrx/signals':
+ specifier: ^21.0.1
+ version: 21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1)
'@nx/angular':
specifier: 22.5.4
version: 22.5.4(a5eeeec2ccebf74e909b99894b49c4c1)
@@ -421,6 +427,18 @@ packages:
'@angular-devkit/architect': '>=0.1500.0 < 0.2200.0'
vitest: ^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0
+ '@angular-architects/ngrx-toolkit@21.0.1':
+ resolution: {integrity: sha512-mbwNxO+HIhf5ocUdgj7ywrSCpLLzgAg+whktDDhBoVQfLdlTZKoI6NRuOAjxTksaaok85Wv6+nHGBRuJLDIjsQ==}
+ peerDependencies:
+ '@angular/common': ^21.0.0
+ '@angular/core': ^21.0.0
+ '@ngrx/signals': ^21.0.0
+ '@ngrx/store': ^21.0.0
+ rxjs: ^7.0.0
+ peerDependenciesMeta:
+ '@ngrx/store':
+ optional: true
+
'@angular-devkit/architect@0.2101.0':
resolution: {integrity: sha512-vnNAzWXwSRGTHk2K7woIQsj7WDYZp69Z3DBdlxkK0H08ymkJ/ELbhN0/AnIJNNtYCqEb57AH7Ro98n422beDuw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -3088,6 +3106,15 @@ packages:
'@ngrx/schematics@19.0.1':
resolution: {integrity: sha512-kH91X7zewB8niOeemk8NgbjnnW0crwqTl8gG2zZAySW3Q0yGnoPpVAc4Yxezm1/bKsLGLILvEAzzlQGKBrigVg==}
+ '@ngrx/signals@21.0.1':
+ resolution: {integrity: sha512-krmZDhgHrnmZrxfEJ41bp/aM8Mc55k5B2N7oCLT5w4M3YbOkbnWPkP6bBWMv4XPI+2rqVgkLRW6DaWLwoESaBw==}
+ peerDependencies:
+ '@angular/core': ^21.0.0
+ rxjs: ^6.5.3 || ^7.4.0
+ peerDependenciesMeta:
+ rxjs:
+ optional: true
+
'@ngtools/webpack@21.2.1':
resolution: {integrity: sha512-HGRGTDmyo3IuxxEAVK9/QK2Nd/nwWIh/zcd2x6nmCxC7tdB0fwIhEZhWnUDyLP2QnDaEPeR4NnZCGTN89SWhGg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -11027,6 +11054,14 @@ snapshots:
'@angular-devkit/architect': 0.2102.1(chokidar@5.0.0)
vitest: 4.1.0-beta.4(@types/node@18.16.9)(@vitest/browser-playwright@4.1.0-beta.4)(jsdom@27.4.0)(vite@7.3.1(@types/node@18.16.9)(jiti@2.4.2)(less@4.5.1)(sass-embedded@1.96.0)(sass@1.97.1)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ '@angular-architects/ngrx-toolkit@21.0.1(@angular/common@21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(@ngrx/signals@21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(rxjs@7.8.1)':
+ dependencies:
+ '@angular/common': 21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1)
+ '@angular/core': 21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0)
+ '@ngrx/signals': 21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1)
+ rxjs: 7.8.1
+ tslib: 2.8.1
+
'@angular-devkit/architect@0.2101.0(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 21.1.0(chokidar@5.0.0)
@@ -14624,6 +14659,13 @@ snapshots:
'@ngrx/schematics@19.0.1': {}
+ '@ngrx/signals@21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1)':
+ dependencies:
+ '@angular/core': 21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0)
+ tslib: 2.8.1
+ optionalDependencies:
+ rxjs: 7.8.1
+
'@ngtools/webpack@21.2.1(@angular/compiler-cli@21.2.2(@angular/compiler@21.2.2)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.105.2(@swc/core@1.15.8(@swc/helpers@0.5.19)))':
dependencies:
'@angular/compiler-cli': 21.2.2(@angular/compiler@21.2.2)(typescript@5.9.3)