diff --git a/projects/ngrx.io/content/guide/store/action-groups.md b/projects/ngrx.io/content/guide/store/action-groups.md index 31fe2592b2..fd84da3964 100644 --- a/projects/ngrx.io/content/guide/store/action-groups.md +++ b/projects/ngrx.io/content/guide/store/action-groups.md @@ -2,14 +2,12 @@
- -
diff --git a/projects/ngrx.io/content/guide/store/feature-creators.md b/projects/ngrx.io/content/guide/store/feature-creators.md index beceb2776f..e78a8de7df 100644 --- a/projects/ngrx.io/content/guide/store/feature-creators.md +++ b/projects/ngrx.io/content/guide/store/feature-creators.md @@ -1,4 +1,5 @@ # Feature Creators +
-
diff --git a/projects/www/src/app/pages/guide/component-store/effect.md b/projects/www/src/app/pages/guide/component-store/effect.md index eb98c702c8..919bd4cf21 100644 --- a/projects/www/src/app/pages/guide/component-store/effect.md +++ b/projects/www/src/app/pages/guide/component-store/effect.md @@ -99,7 +99,7 @@ To make this possible set the generic type of the `effect` method to `void`. ```ts - readonly getAllMovies = this.effect( +readonly getAllMovies = this.effect( // The name of the source stream doesn't matter: `trigger$`, `source$` or `$` are good // names. We encourage to choose one of these and use them consistently in your codebase. (trigger$) => trigger$.pipe( diff --git a/projects/www/src/app/pages/guide/component-store/read.md b/projects/www/src/app/pages/guide/component-store/read.md index f53cee7e98..965a70f69c 100644 --- a/projects/www/src/app/pages/guide/component-store/read.md +++ b/projects/www/src/app/pages/guide/component-store/read.md @@ -98,7 +98,7 @@ The `select` method accepts a dictionary of observables as input and returns an ```ts - private readonly vm$ = this.select({ +private readonly vm$ = this.select({ movies: this.movies$, userPreferredMovieIds: this.userPreferredMovieIds$, userPreferredMovies: this.userPreferredMovies$ diff --git a/projects/www/src/app/pages/guide/component-store/usage.md b/projects/www/src/app/pages/guide/component-store/usage.md index 8de0c886e2..313324e603 100644 --- a/projects/www/src/app/pages/guide/component-store/usage.md +++ b/projects/www/src/app/pages/guide/component-store/usage.md @@ -99,11 +99,16 @@ Below are the steps of integrating `ComponentStore` into a component. First, the state for the component needs to be identified. In `SlideToggleComponent` only the state of whether the toggle is turned ON or OFF is stored. - -`ts` +```ts +export interface SlideToggleState { + checked: boolean; +} +``` @@ -120,7 +125,11 @@ In this example `ComponentStore` is provided directly in the component. This wor path="component-store-slide-toggle/src/app/slide-toggle.component.ts" region="providers"> -`ts` +```ts +@Component({ + selector: 'mat-slide-toggle', + templateUrl: 'slide-toggle.html', +``` @@ -138,11 +147,21 @@ When it is called with a callback, the state is updated. - -`ts` +```ts +constructor( + private readonly componentStore: ComponentStore +) { + // set defaults + this.componentStore.setState({ + checked: false, + }); +} +``` @@ -159,7 +178,11 @@ When a user clicks the toggle (triggering a 'change' event), instead of calling path="component-store-slide-toggle/src/app/slide-toggle.component.ts" region="updater"> -`ts` +```ts +@Input() set checked(value: boolean) { + this.setChecked(value); + } +``` @@ -170,11 +193,18 @@ Finally, the state is aggregated with selectors into two properties: - `vm$` property collects all the data needed for the template - this is the _ViewModel_ of `SlideToggleComponent`. - `change` is the `@Output` of `SlideToggleComponent`. Instead of creating an `EventEmitter`, here the output is connected to the Observable source directly. - -`ts` +```ts +// Observable used instead of EventEmitter + @Output() readonly change = this.componentStore.select((state) => ({ + source: this, + checked: state.checked, + })); +``` @@ -228,11 +258,28 @@ You can see the examples at StackBlitz: With `ComponentStore` extracted into `PaginatorStore`, the developer is now using updaters and effects to update the state. `@Input` values are passed directly into `updater`s as their arguments. - -`ts` +```ts +@Input() set pageIndex(value: string | number) { + this.paginatorStore.setPageIndex(value); + } + + @Input() set length(value: string | number) { + this.paginatorStore.setLength(value); + } + + @Input() set pageSize(value: string | number) { + this.paginatorStore.setPageSize(value); + } + + @Input() set pageSizeOptions(value: readonly number[]) { + this.paginatorStore.setPageSizeOptions(value); + } +``` @@ -240,11 +287,28 @@ Not all `updater`s have to be called in the `@Input`. For example, `changePageSi Effects are used to perform additional validation and get extra information from sources with derived data (i.e. selectors). - -`ts` +```ts +changePageSize(newPageSize: number) { + this.paginatorStore.changePageSize(newPageSize); + } + nextPage() { + this.paginatorStore.nextPage(); + } + firstPage() { + this.paginatorStore.firstPage(); + } + previousPage() { + this.paginatorStore.previousPage(); + } + lastPage() { + this.paginatorStore.lastPage(); + } +``` diff --git a/projects/www/src/app/pages/guide/component/index.md b/projects/www/src/app/pages/guide/component/index.md index 9dccc808cf..e995bfd803 100644 --- a/projects/www/src/app/pages/guide/component/index.md +++ b/projects/www/src/app/pages/guide/component/index.md @@ -1,3 +1,10 @@ + + +The `@ngrx/component` package is in maintenance mode. +Changes to this package are limited to critical bug fixes. + + + # @ngrx/component Component is a library for building reactive Angular templates. diff --git a/projects/www/src/app/pages/guide/component/install.md b/projects/www/src/app/pages/guide/component/install.md index 2658a596bd..cd2adc659e 100644 --- a/projects/www/src/app/pages/guide/component/install.md +++ b/projects/www/src/app/pages/guide/component/install.md @@ -1,3 +1,10 @@ + + +The `@ngrx/component` package is in maintenance mode. +Changes to this package are limited to critical bug fixes. + + + # Installation ## Installing with `ng add` diff --git a/projects/www/src/app/pages/guide/component/let.md b/projects/www/src/app/pages/guide/component/let.md index 03592a99c9..10cce3276c 100644 --- a/projects/www/src/app/pages/guide/component/let.md +++ b/projects/www/src/app/pages/guide/component/let.md @@ -1,3 +1,10 @@ + + +The `@ngrx/component` package is in maintenance mode. +Changes to this package are limited to critical bug fixes. + + + # Let Directive The `*ngrxLet` directive serves a convenient way of binding observables to a view context diff --git a/projects/www/src/app/pages/guide/component/push.md b/projects/www/src/app/pages/guide/component/push.md index 415077cc5a..a575c9b0cd 100644 --- a/projects/www/src/app/pages/guide/component/push.md +++ b/projects/www/src/app/pages/guide/component/push.md @@ -1,3 +1,10 @@ + + +The `@ngrx/component` package is in maintenance mode. +Changes to this package are limited to critical bug fixes. + + + # Push Pipe The `ngrxPush` pipe serves as a drop-in replacement for the `async` pipe. diff --git a/projects/www/src/app/pages/guide/data/entity-actions.md b/projects/www/src/app/pages/guide/data/entity-actions.md index 57cd4f5831..8d0f8a9ca9 100644 --- a/projects/www/src/app/pages/guide/data/entity-actions.md +++ b/projects/www/src/app/pages/guide/data/entity-actions.md @@ -124,7 +124,8 @@ For example, the default generated `Action.type` for the operation that queries The `EntityActionFactory.create()` method calls the factory's `formatActionType()` method to produce the `Action.type` string. -Because NgRx Data ignores the `type`, you can replace `formatActionType()` with your own method if you prefer a different format or provide and inject your own `EntityActionFactory`. +Because NgRx Data ignores the `type`, you can replace `formatActionType()` with your own method if you prefer a different format +or provide and inject your own `EntityActionFactory`. diff --git a/projects/www/src/app/pages/guide/data/entity-metadata.md b/projects/www/src/app/pages/guide/data/entity-metadata.md index 0c67f3ecb0..73f8fdb118 100644 --- a/projects/www/src/app/pages/guide/data/entity-metadata.md +++ b/projects/www/src/app/pages/guide/data/entity-metadata.md @@ -155,10 +155,9 @@ export function nameAndSayingFilter( entities: Villain[], pattern: string ) { - return ( - PropsFilterFnFactory < - Villain > - ['name', 'saying'](entities, pattern) + return PropsFilterFnFactory[('name', 'saying')]( + entities, + pattern ); } ``` diff --git a/projects/www/src/app/pages/guide/effects/index.md b/projects/www/src/app/pages/guide/effects/index.md index 99db705e8e..f21a6937d3 100644 --- a/projects/www/src/app/pages/guide/effects/index.md +++ b/projects/www/src/app/pages/guide/effects/index.md @@ -342,7 +342,7 @@ export const routes: Route[] = [ You can provide root-/feature-level effects with the provider `USER_PROVIDED_EFFECTS`. - + ```ts providers: [ diff --git a/projects/www/src/app/pages/guide/eslint-plugin/index.md b/projects/www/src/app/pages/guide/eslint-plugin/index.md index d7b9a6a1de..4e641a33de 100644 --- a/projects/www/src/app/pages/guide/eslint-plugin/index.md +++ b/projects/www/src/app/pages/guide/eslint-plugin/index.md @@ -126,6 +126,7 @@ module.exports = tseslint.config({ | Name | Description | Category | Fixable | Has suggestions | Configurable | Requires type information | | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------- | ------- | --------------- | ------------ | ------------------------- | +| [@ngrx/enforce-type-call](/guide/eslint-plugin/rules/enforce-type-call) | The `type` function must be called. | problem | Yes | No | No | No | | [@ngrx/prefer-protected-state](/guide/eslint-plugin/rules/prefer-protected-state) | A Signal Store prefers protected state | suggestion | No | Yes | No | No | | [@ngrx/signal-state-no-arrays-at-root-level](/guide/eslint-plugin/rules/signal-state-no-arrays-at-root-level) | signalState should accept a record or dictionary as an input argument. | problem | No | No | No | No | | [@ngrx/signal-store-feature-should-use-generic-type](/guide/eslint-plugin/rules/signal-store-feature-should-use-generic-type) | A custom Signal Store feature that accepts an input should define a generic type. | problem | Yes | No | No | No | diff --git a/projects/www/src/app/pages/guide/eslint-plugin/rules/enforce-type-call.md b/projects/www/src/app/pages/guide/eslint-plugin/rules/enforce-type-call.md index e6fd8b8831..7c58443e19 100644 --- a/projects/www/src/app/pages/guide/eslint-plugin/rules/enforce-type-call.md +++ b/projects/www/src/app/pages/guide/eslint-plugin/rules/enforce-type-call.md @@ -10,3 +10,35 @@ The `type` function must be called. + +## Rule Details + +This rule ensures that the `type` function from `@ngrx/signals` is properly called when used to define types. The function must be invoked with parentheses `()` after the generic type parameter. + +Examples of **incorrect** code for this rule: + +```ts +import { type } from '@ngrx/signals'; +const stateType = type<{ count: number }>; +``` + +```ts +import { type as typeFn } from '@ngrx/signals'; +const stateType = typeFn<{ count: number }>; +``` + +Examples of **correct** code for this rule: + +```ts +import { type } from '@ngrx/signals'; +const stateType = type<{ count: number }>(); +``` + +```ts +import { type as typeFn } from '@ngrx/signals'; +const stateType = typeFn<{ count: number; name: string }>(); +``` + +## Further reading + +- [Signal Store Documentation](guide/signals/signal-store) diff --git a/projects/www/src/app/pages/guide/operators/operators.md b/projects/www/src/app/pages/guide/operators/operators.md index 94d7cfa49f..5ac0fad6b6 100644 --- a/projects/www/src/app/pages/guide/operators/operators.md +++ b/projects/www/src/app/pages/guide/operators/operators.md @@ -22,45 +22,37 @@ The `concatLatestFrom` operator has been moved from `@ngrx/effects` to `@ngrx/op - + ```ts import { Injectable } from '@angular/core'; import { Title } from '@angular/platform-browser'; -import { map, tap } from 'rxjs/operators'; +import { map, tap } from 'rxjs'; -import { - Actions, - concatLatestFrom, - createEffect, - ofType, -} from '@ngrx/effects'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { routerNavigatedAction } from '@ngrx/router-store'; +import { concatLatestFrom } from '@ngrx/operators'; -import { selectRouteData } from './router.selectors'; +import { selectRouteData } from './router-selectors'; @Injectable() export class RouterEffects { + readonly #actions$ = inject(Actions); + readonly #store = inject(Store); + readonly #titleService = inject(Title); + updateTitle$ = createEffect( () => - this.actions$.pipe( + this.#actions$.pipe( ofType(routerNavigatedAction), - concatLatestFrom(() => this.store.select(selectRouteData)), + concatLatestFrom(() => this.#store.select(selectRouteData)), map(([, data]) => `Book Collection - ${data['title']}`), - tap((title) => this.titleService.setTitle(title)) + tap((title) => this.#titleService.setTitle(title)) ), - { - dispatch: false, - } + { dispatch: false } ); - - constructor( - private actions$: Actions, - private store: Store, - private titleService: Title - ) {} } ``` @@ -79,44 +71,64 @@ The `tapResponse` operator has been moved from `@ngrx/component-store` to `@ngrx - + ```ts - readonly getMovie = this.effect((movieId$: Observable) => { - return movieId$.pipe( +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { tapResponse } from '@ngrx/operators'; +// ... other imports + +@Injectable() +export class MoviesStore { + // ... other store members + + readonly loadMovie = rxMethod( + pipe( // πŸ‘‡ Handle race condition with the proper choice of the flattening operator. - switchMap((id) => this.moviesService.fetchMovie(id).pipe( - //πŸ‘‡ Act on the result within inner pipe. - tapResponse( - (movie) => this.addMovie(movie), - (error: HttpErrorResponse) => this.logError(error), - ), - )), - ); - }); + switchMap(() => + this.moviesService.getMovie(id).pipe( + //πŸ‘‡ Act on the result within inner pipe. + tapResponse({ + next: (movie) => this.addMovie(movie), + error: (error: HttpErrorResponse) => this.logError(error), + }) + ) + ) + ) + ); +} ``` -There is also another signature of the `tapResponse` operator that accepts the observer object as an input argument. In addition to the `next` and `error` callbacks, it provides the ability to pass `complete` and/or `finalize` callbacks: +In addition to the `next` and `error` callbacks, `tapResponse` provides the ability to pass `complete` and/or `finalize` callbacks: - + ```ts - readonly getMoviesByQuery = this.effect((query$) => { - return query$.pipe( - tap(() => this.patchState({ loading: true }), +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { tapResponse } from '@ngrx/operators'; +// ... other imports + +@Injectable() +export class MoviesStore { + // ... other store members + + readonly loadMoviesByQuery = rxMethod( + pipe( + tap(() => this.isLoading.set(true), switchMap((query) => - this.moviesService.fetchMoviesByQuery(query).pipe( + this.moviesService.getMoviesByQuery(query).pipe( tapResponse({ - next: (movies) => this.patchState({ movies }), + next: (movies) => this.movies.set(movies), error: (error: HttpErrorResponse) => this.logError(error), - finalize: () => this.patchState({ loading: false }), + finalize: () => this.isLoading.set(false), }) ) ) - ); - }); + ) + ); +} ``` @@ -127,9 +139,13 @@ The `mapResponse` operator is particularly useful in scenarios where you need to In the example below, we use `mapResponse` within an NgRx effect to handle loading movies from an API. It demonstrates how to map successful API responses to an action indicating success, and how to handle errors by dispatching an error action. - + ```ts +import { createEffect } from '@ngrx/effects'; +import { mapResponse } from '@ngrx/operators'; +// ...other imports + export const loadMovies = createEffect( ( actions$ = inject(Actions), diff --git a/projects/www/src/app/pages/guide/router-store/selectors.md b/projects/www/src/app/pages/guide/router-store/selectors.md index 384b462873..7787102a85 100644 --- a/projects/www/src/app/pages/guide/router-store/selectors.md +++ b/projects/www/src/app/pages/guide/router-store/selectors.md @@ -19,15 +19,118 @@ You can see the full example at StackBlitz: + +```ts +import { + getRouterSelectors, + RouterReducerState, +} from '@ngrx/router-store'; + +// `router` is used as the default feature name. You can use the feature name +// of your choice by creating a feature selector and pass it to the `getRouterSelectors` function +// export const selectRouter = createFeatureSelector('yourFeatureName'); + +export const { + selectCurrentRoute, // select the current route + selectFragment, // select the current route fragment + selectQueryParams, // select the current route query params + selectQueryParam, // factory function to select a query param + selectRouteParams, // select the current route params + selectRouteParam, // factory function to select a route param + selectRouteData, // select the current route data + selectRouteDataParam, // factory function to select a route data param + selectUrl, // select the current url + selectTitle, // select the title if available +} = getRouterSelectors(); +``` + + +```ts +import { createReducer, on } from '@ngrx/store'; +import { EntityState, createEntityAdapter } from '@ngrx/entity'; +import { appInit } from './car.actions'; + +export interface Car { + id: string; + year: string; + make: string; + model: string; +} + +export type CarState = EntityState; + +export const carAdapter = createEntityAdapter({ + selectId: (car) => car.id, +}); + +const initialState = carAdapter.getInitialState(); + +export const reducer = createReducer( + initialState, + on(appInit, (state, { cars }) => carAdapter.addMany(cars, state)) +); +``` + + +```ts +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { selectRouteParams } from '../router.selectors'; +import { carAdapter, CarState } from './car.reducer'; + +export const carsFeatureSelector = + createFeatureSelector('cars'); + +const { selectEntities, selectAll } = carAdapter.getSelectors(); + +export const selectCarEntities = createSelector( + carsFeatureSelector, + selectEntities +); + +export const selectCars = createSelector( + carsFeatureSelector, + selectAll +); + +// you can combine the `selectRouteParams` with `selectCarEntities` +// to get a selector for the active car for this component based +// on the route +export const selectCar = createSelector( + selectCarEntities, + selectRouteParams, + (cars, { carId }) => cars[carId] +); +``` + + +```ts +import { Component, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { selectCar } from './car.selectors'; +import { AsyncPipe, JsonPipe } from '@angular/common'; + +@Component({ + standalone: true, + selector: 'app-car', + templateUrl: './car.component.html', + styleUrls: ['./car.component.css'], + imports: [AsyncPipe, JsonPipe], +}) +export class CarComponent { + private store = inject(Store); + car$ = this.store.select(selectCar); +} +``` + ## Extracting all params in the current route @@ -57,27 +160,33 @@ Using `selectRouteParam{s}` will get the `matched` param but not the `urlPath` p If all params in the URL Tree need to be extracted (both `urlPath` and `matched`), the following custom selector can be used. It accumulates params of all the segments in the matched route: - + ```ts import { Params } from '@angular/router'; import { createSelector } from '@ngrx/store'; -export const selectRouteNestedParams = createSelector(selectRouter, (router) => { - let currentRoute = router?.state?.root; - let params: Params = {}; - while (currentRoute?.firstChild) { - currentRoute = currentRoute.firstChild; - params = { - ...params, - ...currentRoute.params, - }; +export const selectRouteNestedParams = createSelector( + selectRouter, + (router) => { + let currentRoute = router?.state?.root; + let params: Params = {}; + while (currentRoute?.firstChild) { + currentRoute = currentRoute.firstChild; + params = { + ...params, + ...currentRoute.params, + }; + } + return params; } - return params; -}); +); export const selectRouteNestedParam = (param: string) => - createSelector(selectRouteNestedParams, (params) => params && params[param]); + createSelector( + selectRouteNestedParams, + (params) => params && params[param] + ); ``` diff --git a/projects/www/src/app/pages/guide/signals/faq.md b/projects/www/src/app/pages/guide/signals/faq.md index 4c351bdfe9..d12b283eab 100644 --- a/projects/www/src/app/pages/guide/signals/faq.md +++ b/projects/www/src/app/pages/guide/signals/faq.md @@ -1,34 +1,30 @@ -# FAQ - - + diff --git a/projects/www/src/app/pages/guide/signals/index.md b/projects/www/src/app/pages/guide/signals/index.md index f24dadaa1e..7dab2b9d43 100644 --- a/projects/www/src/app/pages/guide/signals/index.md +++ b/projects/www/src/app/pages/guide/signals/index.md @@ -20,4 +20,5 @@ Detailed installation instructions can be found on the [Installation](guide/sign - [SignalStore](guide/signals/signal-store): A fully-featured state management solution that provides native support for Angular Signals and offers a robust way to manage application state. - [SignalState](guide/signals/signal-state): A lightweight utility for managing signal-based state in Angular components and services in a concise and minimalistic manner. - [RxJS Integration](guide/signals/rxjs-integration): A plugin for opt-in integration with RxJS, enabling easier handling of asynchronous side effects. -- [Entity Management](guide/signals/signal-store/entity-management): A plugin for manipulating and querying entity collections in a simple and performant way. +- [Entities](guide/signals/signal-store/entity-management): A plugin for manipulating and querying entity collections in a simple and performant way. +- [Events](guide/signals/signal-store/events): A plugin for event-based state management. diff --git a/projects/www/src/app/pages/guide/signals/rxjs-integration.md b/projects/www/src/app/pages/guide/signals/rxjs-integration.md index 8ba928bd0a..a6d50a4d6c 100644 --- a/projects/www/src/app/pages/guide/signals/rxjs-integration.md +++ b/projects/www/src/app/pages/guide/signals/rxjs-integration.md @@ -17,7 +17,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; @Component({ /* ... */ }) -export class NumbersComponent { +export class Numbers { // πŸ‘‡ This reactive method will have an input argument // of type `number | Signal | Observable`. readonly logDoubledNumber = rxMethod( @@ -34,14 +34,14 @@ Each invocation of the reactive method pushes the input value through the reacti When called with a static value, the reactive chain executes once. ```ts -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { map, pipe, tap } from 'rxjs'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers { readonly logDoubledNumber = rxMethod( pipe( map((num) => num * 2), @@ -49,7 +49,7 @@ export class NumbersComponent implements OnInit { ) ); - ngOnInit(): void { + constructor() { this.logDoubledNumber(1); // console output: 2 @@ -62,14 +62,14 @@ export class NumbersComponent implements OnInit { When a reactive method is called with a signal, the reactive chain is executed every time the signal value changes. ```ts -import { Component, OnInit, signal } from '@angular/core'; +import { Component, signal } from '@angular/core'; import { map, pipe, tap } from 'rxjs'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers { readonly logDoubledNumber = rxMethod( pipe( map((num) => num * 2), @@ -77,7 +77,7 @@ export class NumbersComponent implements OnInit { ) ); - ngOnInit(): void { + constructor() { const num = signal(10); this.logDoubledNumber(num); // console output: 20 @@ -91,14 +91,14 @@ export class NumbersComponent implements OnInit { When a reactive method is called with an observable, the reactive chain is executed every time the observable emits a new value. ```ts -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { interval, map, of, pipe, tap } from 'rxjs'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers { readonly logDoubledNumber = rxMethod( pipe( map((num) => num * 2), @@ -106,7 +106,7 @@ export class NumbersComponent implements OnInit { ) ); - ngOnInit(): void { + constructor() { const num1$ = of(100, 200, 300); this.logDoubledNumber(num1$); // console output: 200, 400, 600 @@ -127,17 +127,17 @@ The `rxMethod` is a great choice for handling API calls in a reactive manner. The subsequent example demonstrates how to use `rxMethod` to fetch the book by id whenever the `selectedBookId` signal value changes. ```ts -import { Component, inject, OnInit, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { concatMap, filter, pipe } from 'rxjs'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { tapResponse } from '@ngrx/operators'; -import { Book } from './book.model'; -import { BooksService } from './books.service'; +import { BooksService } from './books-service'; +import { Book } from './book'; @Component({ /* ... */ }) -export class BooksComponent implements OnInit { +export class BookList { readonly #booksService = inject(BooksService); readonly bookMap = signal>({}); @@ -157,7 +157,7 @@ export class BooksComponent implements OnInit { ) ); - ngOnInit(): void { + constructor() { // πŸ‘‡ Load book by id whenever the `selectedBookId` value changes. this.loadBookById(this.selectedBookId); } @@ -186,17 +186,17 @@ Further details can be found in the [Reactive Store Methods](guide/signals/signa To create a reactive method without arguments, the `void` type should be specified as a generic argument to the `rxMethod` function. ```ts -import { Component, inject, OnInit, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { exhaustMap } from 'rxjs'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { tapResponse } from '@ngrx/operators'; -import { Book } from './book.model'; -import { BooksService } from './books.service'; +import { BooksService } from './books-service'; +import { Book } from './book'; @Component({ /* ... */ }) -export class BooksComponent implements OnInit { +export class BookList { readonly #booksService = inject(BooksService); readonly books = signal([]); @@ -212,7 +212,7 @@ export class BooksComponent implements OnInit { }) ); - ngOnInit(): void { + constructor() { this.loadAllBooks(); } } @@ -244,7 +244,7 @@ export class NumbersService { @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers implements OnInit { readonly #injector = inject(Injector); readonly #numbersService = inject(NumbersService); @@ -273,17 +273,17 @@ If the injector is not provided when calling the reactive method with a signal o If a reactive method needs to be cleaned up before the injector is destroyed, manual cleanup can be performed by calling the `destroy` method. ```ts -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { interval, tap } from 'rxjs'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers { readonly logNumber = rxMethod(tap(console.log)); - ngOnInit(): void { + constructor() { const num1$ = interval(500); const num2$ = interval(1_000); @@ -302,17 +302,17 @@ When invoked, the reactive method returns the object with the `destroy` method. This allows manual cleanup of a specific call, preserving the activity of other reactive method calls until the corresponding injector is destroyed. ```ts -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { interval, tap } from 'rxjs'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers { readonly logNumber = rxMethod(tap(console.log)); - ngOnInit(): void { + constructor() { const num1$ = interval(500); const num2$ = interval(1_000); @@ -339,7 +339,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers implements OnInit { readonly #injector = inject(Injector); ngOnInit(): void { diff --git a/projects/www/src/app/pages/guide/signals/signal-method.md b/projects/www/src/app/pages/guide/signals/signal-method.md index 16f55f6c28..266bbe25d5 100644 --- a/projects/www/src/app/pages/guide/signals/signal-method.md +++ b/projects/www/src/app/pages/guide/signals/signal-method.md @@ -9,7 +9,7 @@ import { signalMethod } from '@ngrx/signals'; @Component({ /* ... */ }) -export class NumbersComponent { +export class Numbers { // πŸ‘‡ This method will have an input argument // of type `number | Signal`. readonly logDoubledNumber = signalMethod((num) => { @@ -25,7 +25,7 @@ export class NumbersComponent { @Component({ /* ... */ }) -export class NumbersComponent { +export class Numbers { readonly logDoubledNumber = signalMethod((num) => { const double = num * 2; console.log(double); @@ -48,7 +48,7 @@ export class NumbersComponent { ## Automatic Cleanup `signalMethod` uses an `effect` internally to track the Signal changes. -By default, the `effect` runs in the injection context of the caller. In the example above, that is `NumbersComponent`. That means, that the `effect` is automatically cleaned up when the component is destroyed. +By default, the `effect` runs in the injection context of the caller. In the example above, that is the `Numbers` component. That means, that the `effect` is automatically cleaned up when the component is destroyed. If the call happens outside an injection context, then the injector of the `signalMethod` is used. This would be the case, if `logDoubledNumber` runs in `ngOnInit`: @@ -56,7 +56,7 @@ If the call happens outside an injection context, then the injector of the `sign @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers implements OnInit { readonly logDoubledNumber = signalMethod((num) => { const double = num * 2; console.log(double); @@ -64,13 +64,13 @@ export class NumbersComponent implements OnInit { ngOnInit(): void { const value = signal(2); - // πŸ‘‡ Uses the injection context of the `NumbersComponent`. + // πŸ‘‡ Uses the injection context of the `Numbers` component. this.logDoubledNumber(value); } } ``` -Even though `logDoubledNumber` is called outside an injection context, automatic cleanup occurs when `NumbersComponent` is destroyed, since `logDoubledNumber` was created within the component's injection context. +Even though `logDoubledNumber` is called outside an injection context, automatic cleanup occurs when the `Numbers` component is destroyed, since `logDoubledNumber` was created within the component's injection context. However, when creating a `signalMethod` in an ancestor injection context, the cleanup behavior is different: @@ -86,7 +86,7 @@ export class NumbersService { @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers implements OnInit { readonly numbersService = inject(NumbersService); ngOnInit(): void { @@ -97,7 +97,7 @@ export class NumbersComponent implements OnInit { } ``` -Here, the `effect` outlives the component, which would produce a memory leak. +Here, the `effect` used internally by `signalMethod` outlives the component, which would produce a memory leak. @@ -113,13 +113,13 @@ When a `signalMethod` is created in an ancestor injection context, it's necessar @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers implements OnInit { readonly numbersService = inject(NumbersService); readonly injector = inject(Injector); ngOnInit(): void { const value = signal(1); - // πŸ‘‡ Providing the `NumbersComponent` injector + // πŸ‘‡ Providing the `Numbers` component injector // to ensure cleanup on component destroy. this.numbersService.logDoubledNumber(value, { injector: this.injector, @@ -139,10 +139,10 @@ The `signalMethod` must be initialized within an injection context. To initializ @Component({ /* ... */ }) -export class NumbersComponent implements OnInit { +export class Numbers implements OnInit { readonly injector = inject(Injector); - ngOnInit() { + ngOnInit(): void { const logDoubledNumber = signalMethod( (num) => console.log(num * 2), { injector: this.injector } @@ -159,7 +159,7 @@ At first sight, `signalMethod`, might be the same as `effect`: @Component({ /* ... */ }) -export class NumbersComponent { +export class Numbers { readonly num = signal(2); readonly logDoubledNumberEffect = effect(() => { console.log(this.num() * 2); diff --git a/projects/www/src/app/pages/guide/signals/signal-state.md b/projects/www/src/app/pages/guide/signals/signal-state.md index bcc597d162..0c7e41ed40 100644 --- a/projects/www/src/app/pages/guide/signals/signal-state.md +++ b/projects/www/src/app/pages/guide/signals/signal-state.md @@ -9,7 +9,7 @@ SignalState is instantiated using the `signalState` function, which accepts an i ```ts import { signalState } from '@ngrx/signals'; -import { User } from './user.model'; +import { User } from './user'; type UserState = { user: User; isAdmin: boolean }; @@ -121,7 +121,7 @@ patchState(userState, setFirstName('Stevie'), setAdmin()); ### Example 1: SignalState in a Component - + ```ts import { ChangeDetectionStrategy, Component } from '@angular/core'; @@ -138,7 +138,7 @@ import { signalState, patchState } from '@ngrx/signals'; `, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CounterComponent { +export class Counter { readonly state = signalState({ count: 0 }); increment(): void { @@ -160,7 +160,7 @@ export class CounterComponent { ### Example 2: SignalState in a Service - + ```ts import { inject, Injectable } from '@angular/core'; @@ -168,18 +168,18 @@ import { exhaustMap, pipe, tap } from 'rxjs'; import { signalState, patchState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { tapResponse } from '@ngrx/operators'; -import { BooksService } from './books.service'; -import { Book } from './book.model'; +import { BooksService } from './books-service'; +import { Book } from './book'; -type BooksState = { books: Book[]; isLoading: boolean }; +type BookListState = { books: Book[]; isLoading: boolean }; -const initialState: BooksState = { +const initialState: BookListState = { books: [], isLoading: false, }; @Injectable() -export class BooksStore { +export class BookListStore { readonly #booksService = inject(BooksService); readonly #state = signalState(initialState); @@ -206,7 +206,7 @@ export class BooksStore { - + ```ts import { @@ -215,10 +215,10 @@ import { inject, OnInit, } from '@angular/core'; -import { BooksStore } from './books.store'; +import { BookListStore } from './book-list-store'; @Component({ - selector: 'ngrx-books', + selector: 'ngrx-book-list', template: `

Books

@@ -232,13 +232,13 @@ import { BooksStore } from './books.store'; } `, - providers: [BooksStore], + providers: [BookListStore], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BooksComponent implements OnInit { - readonly store = inject(BooksStore); +export class BookList { + readonly store = inject(BookListStore); - ngOnInit(): void { + constructor() { this.store.loadBooks(); } } diff --git a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md index caf0efe367..c89b103b42 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md @@ -10,7 +10,7 @@ A custom feature is created using the `signalStoreFeature` function, which accep The following example demonstrates how to create a custom feature that includes the `requestStatus` state slice along with computed signals for checking the request status. - + ```ts import { computed } from '@angular/core'; @@ -46,7 +46,7 @@ export function withRequestStatus() { In addition to the state slice and computed signals, this feature also specifies a set of state updaters for modifying the request status. - + ```ts export function setPending(): RequestStatusState { @@ -72,7 +72,7 @@ For a custom feature, it is recommended to define state updaters as standalone f The `withRequestStatus` feature and updaters can be used to add the `requestStatus` state slice, along with the `isPending`, `isFulfilled`, and `error` computed signals to the `BooksStore`, as follows: - + ```ts import { inject } from '@angular/core'; @@ -82,9 +82,9 @@ import { setFulfilled, setPending, withRequestStatus, -} from './request-status.feature'; -import { Book } from './book.model'; -import { BooksService } from './books.service'; +} from './with-request-status'; +import { BooksService } from './books-service'; +import { Book } from './book'; export const BooksStore = signalStore( withEntities(), @@ -129,7 +129,7 @@ For more details, refer to the [Entity Management guide](guide/signals/signal-st The following example shows how to create a custom feature that logs SignalStore state changes to the console. - + ```ts import { effect } from '@angular/core'; @@ -157,14 +157,14 @@ export function withLogger(name: string) { The `withLogger` feature can be used in the `BooksStore` as follows: - + ```ts import { signalStore } from '@ngrx/signals'; import { withEntities } from '@ngrx/signals/entities'; -import { withRequestStatus } from './request-status.feature'; -import { withLogger } from './logger.feature'; -import { Book } from './book.model'; +import { withRequestStatus } from './with-request-status'; +import { withLogger } from './with-logger'; +import { Book } from './book'; export const BooksStore = signalStore( withEntities(), @@ -194,7 +194,7 @@ It's recommended to define loosely-coupled/independent features whenever possibl The following example demonstrates how to create the `withSelectedEntity` feature. - + ```ts import { computed } from '@angular/core'; @@ -230,13 +230,13 @@ The `withSelectedEntity` feature adds the `selectedEntityId` state slice and the However, it expects state properties from the `EntityState` type to be defined in that store. These properties can be added to the store by using the `withEntities` feature from the `entities` plugin. - + ```ts import { signalStore } from '@ngrx/signals'; import { withEntities } from '@ngrx/signals/entities'; -import { withSelectedEntity } from './selected-entity.feature'; -import { Book } from './book.model'; +import { withSelectedEntity } from './with-selected-entity'; +import { Book } from './book'; export const BooksStore = signalStore( withEntities(), @@ -261,12 +261,12 @@ The `BooksStore` instance will contain the following properties: The `@ngrx/signals` package offers high-level type safety. Therefore, if `BooksStore` does not contain state properties from the `EntityState` type, the compilation error will occur. - + ```ts import { signalStore } from '@ngrx/signals'; -import { withSelectedEntity } from './selected-entity.feature'; -import { Book } from './book.model'; +import { withSelectedEntity } from './with-selected-entity'; +import { Book } from './book'; export const BooksStore = signalStore( withState({ books: [] as Book[], isLoading: false }), @@ -281,7 +281,7 @@ export const BooksStore = signalStore( In addition to state, it's also possible to define expected properties and methods in the following way: - + ```ts import { Signal } from '@angular/core'; @@ -307,6 +307,50 @@ export function withBaz() { The `withBaz` feature can only be used in a store where the property `foo` and the method `bar` are defined. +## Using `withFeature` + +An alternative approach to custom features with input is using the `withFeature` utility, which offers more flexibility. + +The `withFeature` function accepts a callback that receives a store instance and returns a SignalStore feature. +This enables using features that rely on external inputs without depending on the store’s internal structure. + +```ts +import { computed, Signal } from '@angular/core'; +import { + patchState, + signalStore, + signalStoreFeature, + withComputed, + withFeature, + withMethods, + withState, +} from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; +import { Book } from './book'; + +export function withBooksFilter(books: Signal) { + return signalStoreFeature( + withState({ query: '' }), + withComputed(({ query }) => ({ + filteredBooks: computed(() => + books().filter((b) => b.name.includes(query())) + ), + })), + withMethods((store) => ({ + setQuery(query: string): void { + patchState(store, { query }); + }, + })) + ); +} + +export const BooksStore = signalStore( + withEntities(), + // πŸ‘‡ Using `withFeature` to pass input to the `withBooksFilter` feature. + withFeature(({ entities }) => withBooksFilter(entities)) +); +``` + ## Known TypeScript Issues Combining multiple custom features with static input may cause unexpected compilation errors: @@ -359,50 +403,3 @@ const Store = signalStore( withW() ); // βœ… works as expected ``` - -For more complicated use cases, `withFeature` offers an alternative approach. - -## Connecting a Custom Feature with the Store - -The `withFeature` function allows passing properties, methods, or signals from a SignalStore to a custom feature. - -This is an alternative to the input approach above and allows more flexibility: - - - -```ts -import { computed, Signal } from '@angular/core'; -import { - patchState, - signalStore, - signalStoreFeature, - withComputed, - withFeature, - withMethods, - withState, -} from '@ngrx/signals'; -import { withEntities } from '@ngrx/signals/entities'; - -export function withBooksFilter(books: Signal) { - return signalStoreFeature( - withState({ query: '' }), - withComputed(({ query }) => ({ - filteredBooks: computed(() => - books().filter((b) => b.name.includes(query())) - ), - })), - withMethods((store) => ({ - setQuery(query: string): void { - patchState(store, { query }); - }, - })) - ); -} - -export const BooksStore = signalStore( - withEntities(), - withFeature(({ entities }) => withBooksFilter(entities)) -); -``` - - diff --git a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-properties.md b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-properties.md index a2014a84be..cee4540c95 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-properties.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-properties.md @@ -8,12 +8,12 @@ The factory function receives an object containing state signals, previously def `withProps` can be useful for exposing observables from a SignalStore, which can serve as integration points with RxJS-based APIs: - + ```ts import { toObservable } from '@angular/core/rxjs-interop'; import { signalStore, withProps, withState } from '@ngrx/signals'; -import { Book } from './book.model'; +import { Book } from './book'; type BooksState = { books: Book[]; @@ -34,14 +34,14 @@ export const BooksStore = signalStore( Dependencies required across multiple store features can be grouped using `withProps`: - + ```ts import { inject } from '@angular/core'; import { signalStore, withProps, withState } from '@ngrx/signals'; import { Logger } from './logger'; -import { Book } from './book.model'; -import { BooksService } from './books.service'; +import { BooksService } from './books-service'; +import { Book } from './book'; type BooksState = { books: Book[]; diff --git a/projects/www/src/app/pages/guide/signals/signal-store/entity-management.md b/projects/www/src/app/pages/guide/signals/signal-store/entity-management.md index f2c9b7ce84..7c5c723c38 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/entity-management.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/entity-management.md @@ -8,7 +8,7 @@ This plugin provides the `withEntities` feature and a set of entity updaters. The `withEntities` feature integrates entity state into the store. By default, `withEntities` requires an entity to have an `id` property, which serves as a unique identifier and must be of type `EntityId` (either a `string` or a `number`). - + ```ts import { computed } from '@angular/core'; @@ -39,7 +39,7 @@ The `ids` and `entityMap` are state slices, while `entities` is a computed signa The `entities` plugin provides a set of standalone entity updaters. These functions can be used with `patchState` to facilitate entity collection updates. - + ```ts import { patchState, signalStore, withMethods } from '@ngrx/signals'; @@ -265,10 +265,9 @@ The selector's return type should be either `string` or `number`. Custom ID selectors should be provided when adding, setting, or updating entities. Therefore, all variations of the `add*`, `set*`, and `update*` functions include an optional second argument, which is a config object that allows specifying the `selectId` function. - + ```ts - import { patchState, signalStore, withMethods } from '@ngrx/signals'; import { addEntities, @@ -307,7 +306,6 @@ export const TodosStore = signalStore( }, })) ); - ``` @@ -318,7 +316,7 @@ The `remove*` updaters automatically select the correct identifier, so it is not The `withEntities` feature allows specifying a custom prefix for entity properties by providing a collection name as an input argument. - + ```ts import { signalStore, type } from '@ngrx/signals'; @@ -342,7 +340,7 @@ The names of the `TodosStore` properties are changed from `ids`, `entityMap`, an All updaters that operate on named entity collections require a collection name. - + ```ts import { @@ -411,7 +409,7 @@ Although it is possible to manage multiple collections in one store, in most cas The `entityConfig` function reduces repetitive code when defining a custom entity configuration and ensures strong typing. It accepts a config object where the entity type is required, and the collection name and custom ID selector are optional. - + ```ts import { @@ -481,7 +479,7 @@ const TodosStore = signalStore( `, providers: [TodosStore], }) -class TodosComponent { +class Todos { readonly store = inject(TodosStore); } ``` diff --git a/projects/www/src/app/pages/guide/signals/signal-store/events.md b/projects/www/src/app/pages/guide/signals/signal-store/events.md new file mode 100644 index 0000000000..79a7877bee --- /dev/null +++ b/projects/www/src/app/pages/guide/signals/signal-store/events.md @@ -0,0 +1,392 @@ + + +The Events plugin is currently marked as experimental. +This means its APIs are subject to change, and modifications may occur in future versions without standard breaking change announcements until it is deemed stable. + + + +# Events + +The Events plugin extends SignalStore with an event-based state management layer. +It takes inspiration from the original Flux architecture and incorporates the best practices and patterns from NgRx Store, NgRx Effects, and RxJS. + +
+ Application Architecture with Events Plugin +
+ +The application architecture with the Events plugin is composed of the following building blocks: + +1. **Event:** Describes an occurrence within the system. Events are dispatched to trigger state changes and/or side effects. +2. **Dispatcher:** An event bus that forwards events to their corresponding handlers in the stores. +3. **Store:** Contains reducers and effects that manage state and handle side effects, maintaining a clean and predictable application flow. +4. **View:** Reflects state changes and dispatches new events, enabling continuous interaction between the user interface and the underlying system. + +By dispatching events and reacting to them, the _what_ (the event that occurred) is decoupled from the _how_ (the state changes or side effects that result), leading to predictable data flow and more maintainable code. + + + +While the default SignalStore approach is sufficient for most use cases, the Events plugin excels in more advanced scenarios that involve inter-store coordination or benefit from a decoupled architecture. + + + +## Defining Event Creators + +Event creators are defined using utilities provided by the Events plugin. +The `event` function is used for declaring individual event creators, while the `eventGroup` function enables grouping multiple event creators under a common source. + +### Using `event` Function + +The simplest way to define an event creator is with the `event` function, +which takes an event type and an optional payload schema. +Calling the event creator produces an event object with a `type` property and, if a payload is defined, a `payload` property. + + + +```ts +import { type } from '@ngrx/signals'; +import { event } from '@ngrx/signals/events'; + +export const opened = event('[Book Search Page] Opened'); +export const queryChanged = event( + '[Book Search Page] Query Changed', + // πŸ‘‡ The payload type is defined using the `type` function. + type() +); +``` + + + + + +```ts +import { type } from '@ngrx/signals'; +import { event } from '@ngrx/signals/events'; +import { Book } from './book'; + +export const loadedSuccess = event( + '[Books API] Loaded Success', + type() +); +export const loadedFailure = event( + '[Books API] Loaded Failure', + type() +); +``` + + + + + +It's recommended to use the "[Source] EventName" pattern when defining the event type. + + + +Each of these exported constants is an event creator function. +When called, it returns a plain event object. +For example, calling `opened()` returns an object `{ type: '[Book Search Page] Opened' }`, and calling `loadedSuccess([book1, book2])` returns an object `{ type: '[Books API] Loaded Success', payload: [book1, book2] }`. +The `type` property serves as a unique identifier for the event, and the optional `payload` carries additional data. + +### Using `eventGroup` Function + +Defining many events with the same source can become repetitive. +The `eventGroup` API is used to create a set of events with the common source. +This function takes an object with two properties: + +- `source`: Identifies the origin of the event group (e.g., 'Book Search Page', 'Books API'). +- `events`: A dictionary of named event creators, where each key defines the event name and each value defines the payload type. + +The type of all event creators in the group are prefixed with the provided `source`. + + + +```ts +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +export const bookSearchEvents = eventGroup({ + source: 'Book Search Page', + events: { + // πŸ‘‡ Defining an event creator without a payload. + opened: type(), + queryChanged: type(), + }, +}); +``` + + + + + +```ts +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import { Book } from './book'; + +export const booksApiEvents = eventGroup({ + source: 'Books API', + events: { + loadedSuccess: type(), + loadedFailure: type(), + }, +}); +``` + + + +Event types are automatically formatted as "[Source] EventName". +For example, calling `bookSearchEvents.opened()` yields `{ type: '[Book Search Page] opened' }`, and `booksApiEvents.loadedSuccess([book1, book2])` yields `{ type: '[Books API] loadedSuccess', payload: [book1, book2] }`. + +## Performing State Changes + +To handle state changes in response to events, the Events plugin provides the `withReducer` feature. +Case reducers are defined using the `on` function, which maps one or more events to a case reducer handler. +A handler is a function that receives the dispatched event as the first and the current state as the second argument. +The return value of a case reducer handler can be a partial state object, a partial state updater, or an array of partial state objects and/or updaters. + + + +```ts +import { signalStore, withState } from '@ngrx/signals'; +import { on, withReducer } from '@ngrx/signals/events'; +import { bookSearchEvents } from './book-search-events'; +import { booksApiEvents } from './books-api-events'; +import { Book } from './book'; + +type State = { query: string; books: Book[]; isLoading: boolean }; + +export const BookSearchStore = signalStore( + withState({ query: '', books: [], isLoading: false }), + withReducer( + on(bookSearchEvents.opened, () => ({ isLoading: true })), + on(bookSearchEvents.queryChanged, ({ payload: query }) => ({ + query, + isLoading: true, + })), + on(booksApiEvents.loadedSuccess, ({ payload: books }) => ({ + books, + isLoading: false, + })), + on(booksApiEvents.loadedFailure, () => ({ isLoading: false })) + ) +); +``` + + + +When an event is dispatched, the corresponding case reducer logic runs and the SignalStore's state is updated. + + + +In addition to partial state objects, it's also possible to return a partial state updater or an array of partial state objects and/or updaters as the result of a case reducer handler. + +```ts +const incrementBy = event( + '[Counter Page] Increment By', + type() +); +const increment = event('[Counter Page] Increment'); +const incrementBoth = event('[Counter Page] Increment Both'); + +export const CounterStore = signalStore( + withState({ count1: 0, count2: 0 }), + withReducer( + // πŸ‘‡ Returning a partial state object. + on(incrementBy, (event, state) => ({ + count1: state.count1 + event.payload, + })), + // πŸ‘‡ Returning a partial state updater. + on(increment, () => incrementFirst()), + // πŸ‘‡ Returning an array of partial state updaters. + on(incrementBoth, () => [incrementFirst(), incrementSecond()]) + ) +); + +function incrementFirst(): PartialStateUpdater<{ count1: number }> { + return (state) => ({ count1: state.count1 + 1 }); +} + +function incrementSecond(): PartialStateUpdater<{ count2: number }> { + return (state) => ({ count2: state.count2 + 1 }); +} +``` + + + +## Performing Side Effects + +Side effects are handled using the `withEffects` feature. +This feature accepts a function that receives the store instance as an argument and returns a dictionary of effects. +Each effect is defined as an observable that reacts to specific events using the `Events` service. +This service provides the `on` method that returns an observable of dispatched events filtered by the specified event types. +If an effect returns a new event, that event is automatically dispatched. + + + +```ts +// ... other imports +import { switchMap, tap } from 'rxjs'; +import { Events, withEffects } from '@ngrx/signals/events'; +import { mapResponse } from '@ngrx/operators'; +import { BooksService } from './books-service'; + +export const BookSearchStore = signalStore( + // ... other features + withEffects( + ( + store, + events = inject(Events), + booksService = inject(BooksService) + ) => ({ + loadBooksByQuery$: events + .on(bookSearchEvents.opened, bookSearchEvents.queryChanged) + .pipe( + switchMap(() => + booksService.getByQuery(store.query()).pipe( + mapResponse({ + next: (books) => booksApiEvents.loadedSuccess(books), + error: (error: { message: string }) => + booksApiEvents.loadedFailure(error.message), + }) + ) + ) + ), + logError$: events + .on(booksApiEvents.loadedFailure) + .pipe(tap(({ payload }) => console.error(payload))), + }) + ) +); +``` + + + +## Reading State + +The Events plugin doesn’t change how the state is exposed or consumed. +It only changes how the state is updated (via reducers rather than direct method calls). +Therefore, components can access state and computed signals by using the store instance. + + + +```ts +import { + ChangeDetectionStrategy, + Component, + inject, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { BookSearchStore } from './book-search-store'; + +@Component({ + selector: 'ngrx-book-search', + imports: [FormsModule], + template: ` +

Search Books

+ + + + @if (store.isLoading()) { +

Loading...

+ } + +
    + @for (book of store.books(); track book.id) { +
  • {{ book.title }}
  • + } +
+ `, + providers: [BookSearchStore], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BookSearch { + readonly store = inject(BookSearchStore); +} +``` + +
+ +## Dispatching Events + +Once events and their corresponding handlers have been defined, the remaining step is to dispatch events in response to user interactions or other triggers. +Dispatching an event allows any matching reducers or effects to process it accordingly. + +### Using `Dispatcher` Service + +To initiate state changes or side effects, events can be dispatched using the `Dispatcher` service. +It provides the `dispatch` method that takes an event as input. + + + +```ts +// ... other imports +import { Dispatcher } from '@ngrx/signals/events'; +import { bookSearchEvents } from './book-search-events'; + +@Component({ + // ... component config + template: ` +

Search Books

+ + + + + `, +}) +export class BookSearch { + readonly dispatcher = inject(Dispatcher); + readonly store = inject(BookSearchStore); + + constructor() { + this.dispatcher.dispatch(bookSearchEvents.opened()); + } + + changeQuery(query: string): void { + this.dispatcher.dispatch(bookSearchEvents.queryChanged(query)); + } +} +``` + +
+ +### Using `injectDispatch` Function + +Manually injecting the `Dispatcher` service and invoking the `dispatch` method for each event can lead to repetitive code. +To streamline this process, the Events plugin provides the `injectDispatch` utility. +When invoked with a dictionary of event creators, this function returns an object that reflects the structure of the event definitions. +Each member of the returned object is a method that, when called, automatically creates and dispatches the corresponding event. + + + +```ts +// ... other imports +import { injectDispatch } from '@ngrx/signals/events'; + +@Component({ + // ... component config + template: ` +

Search Books

+ + + + + `, +}) +export class BookSearch { + readonly dispatch = injectDispatch(bookSearchEvents); + readonly store = inject(BookSearchStore); + + constructor() { + this.dispatch.opened(); + } +} +``` + +
diff --git a/projects/www/src/app/pages/guide/signals/signal-store/index.md b/projects/www/src/app/pages/guide/signals/signal-store/index.md index b6f2f5b24a..2af0ef5f40 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/index.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/index.md @@ -13,25 +13,25 @@ Based on the utilized features, the `signalStore` function returns an injectable The `withState` feature is used to add state slices to the SignalStore. This feature accepts initial state as an input argument. As with `signalState`, the state's type must be a record/object literal. - + ```ts import { signalStore, withState } from '@ngrx/signals'; -import { Book } from './book.model'; +import { Book } from './book'; -type BooksState = { +type BookSearchState = { books: Book[]; isLoading: boolean; filter: { query: string; order: 'asc' | 'desc' }; }; -const initialState: BooksState = { +const initialState: BookSearchState = { books: [], isLoading: false, filter: { query: '', order: 'asc' }, }; -export const BooksStore = signalStore(withState(initialState)); +export const BookSearchStore = signalStore(withState(initialState)); ``` @@ -39,7 +39,7 @@ export const BooksStore = signalStore(withState(initialState)); For each state slice, a corresponding signal is automatically created. The same applies to nested state properties, with all deeply nested signals being generated lazily on demand. -The `BooksStore` instance will contain the following properties: +The `BookSearchStore` instance will contain the following properties: - `books: Signal` - `isLoading: Signal` @@ -53,11 +53,14 @@ The `withState` feature also has a signature that takes the initial state factor The factory is executed within the injection context, allowing initial state to be obtained from a service or injection token. ```ts -const BOOKS_STATE = new InjectionToken('BooksState', { - factory: () => initialState, -}); +const BOOK_SEARCH_STATE = new InjectionToken( + 'BookSearchState', + { factory: () => initialState } +); -const BooksStore = signalStore(withState(() => inject(BOOKS_STATE))); +const BookSearchStore = signalStore( + withState(() => inject(BOOK_SEARCH_STATE)) +); ```
@@ -67,19 +70,19 @@ const BooksStore = signalStore(withState(() => inject(BOOKS_STATE))); SignalStore can be provided locally and globally. By default, a SignalStore is not registered with any injectors and must be included in a providers array at the component, route, or root level before injection. - + ```ts import { Component, inject } from '@angular/core'; -import { BooksStore } from './books.store'; +import { BookSearchStore } from './book-search-store'; @Component({ /* ... */ - // πŸ‘‡ Providing `BooksStore` at the component level. - providers: [BooksStore], + // πŸ‘‡ Providing `BookSearchStore` at the component level. + providers: [BookSearchStore], }) -export class BooksComponent { - readonly store = inject(BooksStore); +export class BookSearch { + readonly store = inject(BookSearchStore); } ``` @@ -88,22 +91,22 @@ export class BooksComponent { When provided at the component level, the store is tied to the component lifecycle, making it useful for managing local/component state. Alternatively, a SignalStore can be globally registered by setting the `providedIn` property to `root` when defining the store. - + ```ts import { signalStore, withState } from '@ngrx/signals'; -import { Book } from './book.model'; +import { Book } from './book'; -type BooksState = { +type BookSearchState = { /* ... */ }; -const initialState: BooksState = { +const initialState: BookSearchState = { /* ... */ }; -export const BooksStore = signalStore( - // πŸ‘‡ Providing `BooksStore` at the root level. +export const BookSearchStore = signalStore( + // πŸ‘‡ Providing `BookSearchStore` at the root level. { providedIn: 'root' }, withState(initialState) ); @@ -118,13 +121,12 @@ This is beneficial for managing global state, as it ensures a single shared inst Signals generated for state slices can be utilized to access state values, as demonstrated below. - + ```ts - import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { JsonPipe } from '@angular/common'; -import { BooksStore } from './books.store'; +import { BookSearchStore } from './book-search-store'; @Component({ imports: [JsonPipe], @@ -139,13 +141,12 @@ import { BooksStore } from './books.store';

Query: {{ store.filter.query() }}

Order: {{ store.filter.order() }}

`, - providers: [BooksStore], + providers: [BookSearchStore], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BooksComponent { - readonly store = inject(BooksStore); +export class BookSearch { + readonly store = inject(BookSearchStore); } - ```
@@ -154,35 +155,36 @@ export class BooksComponent { Computed signals can be added to the store using the `withComputed` feature. This feature accepts a factory function as an input argument, which is executed within the injection context. -The factory should return a dictionary of computed signals, utilizing previously defined state signals and properties that are accessible through its input argument. +The factory should return a dictionary containing either computed signals or functions that return values (which are automatically wrapped in computed signals), utilizing previously defined state signals, properties, and methods that are accessible through its input argument. - + ```ts import { computed } from '@angular/core'; import { signalStore, withComputed, withState } from '@ngrx/signals'; -import { Book } from './book.model'; +import { Book } from './book'; -type BooksState = { +type BookSearchState = { /* ... */ }; -const initialState: BooksState = { +const initialState: BookSearchState = { /* ... */ }; -export const BooksStore = signalStore( +export const BookSearchStore = signalStore( withState(initialState), // πŸ‘‡ Accessing previously defined state signals and properties. withComputed(({ books, filter }) => ({ booksCount: computed(() => books().length), - sortedBooks: computed(() => { + // πŸ‘‡ Adds computed automatically + sortedBooks: () => { const direction = filter.order() === 'asc' ? 1 : -1; return books().toSorted( (a, b) => direction * a.title.localeCompare(b.title) ); - }), + }, })) ); ``` @@ -203,7 +205,7 @@ This feature takes a factory function as an input argument and returns a diction Similar to `withComputed`, the `withMethods` factory is also executed within the injection context. The store instance, including previously defined state signals, properties, and methods, is accessible through the factory input. - + ```ts import { computed } from '@angular/core'; @@ -214,17 +216,17 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { Book } from './book.model'; +import { Book } from './book'; -type BooksState = { +type BookSearchState = { /* ... */ }; -const initialState: BooksState = { +const initialState: BookSearchState = { /* ... */ }; -export const BooksStore = signalStore( +export const BookSearchStore = signalStore( withState(initialState), withComputed(/* ... */), // πŸ‘‡ Accessing a store instance with previously defined state signals, @@ -261,7 +263,7 @@ This is the recommended approach. However, external updates to the state can be enabled by setting the `protectedState` option to `false` when creating a SignalStore. ```ts -export const BooksStore = signalStore( +export const BookSearchStore = signalStore( { protectedState: false }, // πŸ‘ˆ withState(initialState) ); @@ -269,11 +271,11 @@ export const BooksStore = signalStore( @Component({ /* ... */ }) -export class BooksComponent { - readonly store = inject(BooksStore); +export class BookSearch { + readonly store = inject(BookSearchStore); addBook(book: Book): void { - // ⚠️ The state of the `BooksStore` is unprotected from external modifications. + // ⚠️ The state of the `BookSearchStore` is unprotected from external modifications. patchState(this.store, ({ books }) => ({ books: [...books, book], })); @@ -286,23 +288,23 @@ export class BooksComponent { In addition to methods for updating state, the `withMethods` feature can also be used to create methods for performing side effects. Asynchronous side effects can be executed using Promise-based APIs, as demonstrated below. - + ```ts import { computed, inject } from '@angular/core'; import { patchState, signalStore /* ... */ } from '@ngrx/signals'; -import { Book } from './book.model'; -import { BooksService } from './books.service'; +import { BooksService } from './books-service'; +import { Book } from './book'; -type BooksState = { +type BookSearchState = { /* ... */ }; -const initialState: BooksState = { +const initialState: BookSearchState = { /* ... */ }; -export const BooksStore = signalStore( +export const BookSearchStore = signalStore( withState(initialState), withComputed(/* ... */), // πŸ‘‡ `BooksService` can be injected within the `withMethods` factory. @@ -326,7 +328,7 @@ export const BooksStore = signalStore( In more complex scenarios, opting for RxJS to handle asynchronous side effects is advisable. To create a reactive SignalStore method that harnesses RxJS APIs, use the `rxMethod` function from the `rxjs-interop` plugin. - + ```ts import { computed, inject } from '@angular/core'; @@ -340,18 +342,18 @@ import { import { patchState, signalStore /* ... */ } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { tapResponse } from '@ngrx/operators'; -import { Book } from './book.model'; -import { BooksService } from './books.service'; +import { BooksService } from './books-service'; +import { Book } from './book'; -type BooksState = { +type BookSearchState = { /* ... */ }; -const initialState: BooksState = { +const initialState: BookSearchState = { /* ... */ }; -export const BooksStore = signalStore( +export const BookSearchStore = signalStore( withState(initialState), withComputed(/* ... */), withMethods((store, booksService = inject(BooksService)) => ({ @@ -390,9 +392,9 @@ To learn more about the `rxMethod` function, visit the [RxJS Integration](/guide ## Putting It All Together -The final `BooksStore` implementation with state, computed signals, and methods from this guide is shown below. +The final `BookSearchStore` implementation with state, computed signals, and methods from this guide is shown below. - + ```ts import { computed, inject } from '@angular/core'; @@ -412,22 +414,22 @@ import { } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { tapResponse } from '@ngrx/operators'; -import { Book } from './book.model'; -import { BooksService } from './books.service'; +import { BooksService } from './books-service'; +import { Book } from './book'; -type BooksState = { +type BookSearchState = { books: Book[]; isLoading: boolean; filter: { query: string; order: 'asc' | 'desc' }; }; -const initialState: BooksState = { +const initialState: BookSearchState = { books: [], isLoading: false, filter: { query: '', order: 'asc' }, }; -export const BooksStore = signalStore( +export const BookSearchStore = signalStore( withState(initialState), withComputed(({ books, filter }) => ({ booksCount: computed(() => books().length), @@ -472,7 +474,7 @@ export const BooksStore = signalStore( -The `BooksStore` instance will contain the following properties and methods: +The `BookSearchStore` instance will contain the following properties and methods: - State signals: - `books: Signal` @@ -490,32 +492,31 @@ The `BooksStore` instance will contain the following properties and methods: -The `BooksStore` implementation can be enhanced further by utilizing the `entities` plugin and creating custom SignalStore features. +The `BookSearchStore` implementation can be enhanced further by utilizing the `entities` plugin and creating custom SignalStore features. For more details, refer to the [Entity Management](guide/signals/signal-store/entity-management) and [Custom Store Features](guide/signals/signal-store/custom-store-features) guides. -The `BooksComponent` can use the `BooksStore` to manage the state, as demonstrated below. +The `BookSearch` component can use the `BookSearchStore` to manage the state, as demonstrated below. - + ```ts import { ChangeDetectionStrategy, Component, inject, - OnInit, } from '@angular/core'; -import { BooksFilterComponent } from './books-filter.component'; -import { BookListComponent } from './book-list.component'; +import { BookFilter } from './book-filter'; +import { BookList } from './book-list'; import { BooksStore } from './books.store'; @Component({ - imports: [BooksFilterComponent, BookListComponent], + imports: [BookFilter, BookList], template: `

Books ({{ store.booksCount() }})

- `, - providers: [BooksStore], + providers: [BookSearchStore], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BooksComponent implements OnInit { - readonly store = inject(BooksStore); +export class BookSearch { + readonly store = inject(BookSearchStore); - ngOnInit(): void { + constructor() { const query = this.store.filter.query; // πŸ‘‡ Re-fetch books whenever the value of query signal changes. this.store.loadByQuery(query); diff --git a/projects/www/src/app/pages/guide/signals/signal-store/lifecycle-hooks.md b/projects/www/src/app/pages/guide/signals/signal-store/lifecycle-hooks.md index d7511062dd..18de161aab 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/lifecycle-hooks.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/lifecycle-hooks.md @@ -7,7 +7,7 @@ The `withHooks` feature has two signatures. The first signature expects an object with `onInit` and/or `onDestroy` methods. Both methods receive the store instance as input arguments. - + ```ts import { computed } from '@angular/core'; @@ -51,7 +51,7 @@ If there is a need to share code between lifecycle hooks or use injected depende Similar to the `withMethods` and `withComputed` features, the second signature of the `withHooks` feature expects a factory function. This function receives a store instance as an input argument, returns an object with `onInit` and/or `onDestroy` methods, and is executed within the injection context. - + ```ts export const CounterStore = signalStore( diff --git a/projects/www/src/app/pages/guide/signals/signal-store/linked-state.md b/projects/www/src/app/pages/guide/signals/signal-store/linked-state.md new file mode 100644 index 0000000000..cb9865d615 --- /dev/null +++ b/projects/www/src/app/pages/guide/signals/signal-store/linked-state.md @@ -0,0 +1,96 @@ +# Linked State + +The `withLinkedState` feature enables the creation of state slices that depend on other signals. +This feature accepts a factory function as an input argument, which is executed within the injection context. +The factory should return a dictionary containing linked state slices, defined as either computation functions or `WritableSignal` instances. +These linked state slices become an integral part of the SignalStore's state and are treated the same as regular state slices - `DeepSignal`s are created for each of them, and they can be updated using `patchState`. + +## Implicit Linking + +When a computation function is provided, the SignalStore wraps it in a `linkedSignal()`. +As a result, the linked state slice is updated automatically whenever any of its dependent signals change. + + + + +```ts +import { patchState, signalStore, withLinkedState, withState } from '@ngrx/signals'; + +export const OptionsStore = signalStore( + withState({ options: [1, 2, 3] }), + withLinkedState(({ options }) => ({ + // πŸ‘‡ Defining a linked state slice. + selectedOption: () => options()[0] ?? undefined, + })), + withMethods((store) => ({ + setOptions(options: number[]): void { + patchState(store, { options }); + }, + setSelectedOption(selectedOption: number): void { + // πŸ‘‡ Updating a linked state slice. + patchState(store, { selectedOption }); + }, + }), +); +``` + + + + + +```ts +@Component({ + // ... other metadata + providers: [OptionsStore], +}) +export class OptionList { + readonly store = inject(OptionsStore); + + constructor() { + console.log(this.store.selectedOption()); // logs: 1 + + this.store.setSelectedOption(2); + console.log(this.store.selectedOption()); // logs: 2 + + this.store.setOptions([4, 5, 6]); + console.log(this.store.selectedOption()); // logs: 4 + } +} +``` + + + + +## Explicit Linking + +The `withLinkedState` feature also supports providing `WritableSignal` instances as linked state slices. +This can include signals created using `linkedSignal()` with `source` and `computation` options, as well as any other `WritableSignal` instances. +In both cases, the SignalStore and the original signal remain fully synchronized - updating one immediately reflects in the other. + + + +```ts +import { linkedSignal } from '@angular/core'; +import { + signalStore, + withLinkedState, + withState, +} from '@ngrx/signals'; + +export const OptionsStore = signalStore( + withState({ options: [] as Option[] }), + withLinkedState(({ options }) => ({ + selectedOption: linkedSignal({ + source: options, + computation: (newOptions, previous) => { + const option = newOptions.find( + (o) => o.id === previous?.value.id + ); + return option ?? newOptions[0]; + }, + }), + })) +); +``` + + diff --git a/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md b/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md index f5d46f4f76..9c523ff8a9 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md @@ -3,67 +3,67 @@ SignalStore allows defining private members that cannot be accessed from outside the store by using the `_` prefix. This includes root-level state slices, properties, and methods. - + + ```ts import { computed } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { -patchState, -signalStore, -withComputed, -withMethods, -withProps, -withState, + patchState, + signalStore, + withComputed, + withMethods, + withProps, + withState, } from '@ngrx/signals'; export const CounterStore = signalStore( -withState({ -count1: 0, -// πŸ‘‡ private state slice -\_count2: 0, -}), -withComputed(({ count1, \_count2 }) => ({ -// πŸ‘‡ private computed signal -\_doubleCount1: computed(() => count1() _ 2), -doubleCount2: computed(() => \_count2() _ 2), -})), -withProps(({ count2, \_doubleCount1 }) => ({ -// πŸ‘‡ private property -\_count2$: toObservable(count2), - doubleCount1$: toObservable(\_doubleCount1), -})), -withMethods((store) => ({ -increment1(): void { -patchState(store, { count1: store.count1() + 1 }); -}, -// πŸ‘‡ private method -\_increment2(): void { -patchState(store, { \_count2: store.\_count2() + 1 }); -}, -})), + withState({ + count1: 0, + // πŸ‘‡ private state slice + _count2: 0, + }), + withComputed(({ count1, _count2 }) => ({ + // πŸ‘‡ private computed signal + _doubleCount1: computed(() => count1() _ 2), + doubleCount2: computed(() => _count2() _ 2), + })), + withProps(({ count2, _doubleCount1 }) => ({ + // πŸ‘‡ private property + _count2$: toObservable(count2), + doubleCount1$: toObservable(_doubleCount1), + })), + withMethods((store) => ({ + increment1(): void { + patchState(store, { count1: store.count1() + 1 }); + }, + // πŸ‘‡ private method + _increment2(): void { + patchState(store, { _count2: store._count2() + 1 }); + }, + })), ); ``` - + -````ts +```ts import { Component, inject, OnInit } from '@angular/core'; -import { CounterStore } from './counter.store'; +import { CounterStore } from './counter-store'; @Component({ -/_ ... _/ -providers: [CounterStore], + /* ... */ + providers: [CounterStore], }) -export class CounterComponent implements OnInit { -readonly store = inject(CounterStore); +export class Counter implements OnInit { + readonly store = inject(CounterStore); -ngOnInit(): void { -console.log(this.store.count1()); // βœ… -console.log(this.store.\_count2()); // ❌ + ngOnInit(): void { + console.log(this.store.count1()); // βœ… + console.log(this.store._count2()); // ❌ console.log(this.store._doubleCount1()); // ❌ console.log(this.store.doubleCount2()); // βœ… @@ -73,10 +73,9 @@ console.log(this.store.\_count2()); // ❌ this.store.increment1(); // βœ… this.store._increment2(); // ❌ - + } } -} - -```` + diff --git a/projects/www/src/app/pages/guide/signals/signal-store/state-tracking.md b/projects/www/src/app/pages/guide/signals/signal-store/state-tracking.md index 8a404470cd..45610b5c60 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/state-tracking.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/state-tracking.md @@ -7,7 +7,7 @@ State tracking enables the implementation of custom SignalStore features such as The `getState` function is used to get the current state value of the SignalStore. When used within a reactive context, state changes are automatically tracked. - + ```ts import { effect } from '@angular/core'; @@ -54,7 +54,7 @@ It accepts a SignalStore instance as the first argument and a watcher function a By default, the `watchState` function needs to be executed within an injection context. It is tied to its lifecycle and is automatically cleaned up when the injector is destroyed. - + ```ts import { effect } from '@angular/core'; @@ -100,7 +100,7 @@ Conversely, the `effect` function will be executed only once with the final coun If a state watcher needs to be cleaned up before the injector is destroyed, manual cleanup can be performed by calling the `destroy` method. - + ```ts import { @@ -138,18 +138,18 @@ export const CounterStore = signalStore( The `watchState` function can be used outside an injection context by providing an injector as the second argument. - + ```ts import { Component, inject, Injector, OnInit } from '@angular/core'; import { watchState } from '@ngrx/signals'; -import { CounterStore } from './counter.store'; +import { CounterStore } from './counter-store'; @Component({ /* ... */ providers: [CounterStore], }) -export class CounterComponent implements OnInit { +export class Counter implements OnInit { readonly #injector = inject(Injector); readonly store = inject(CounterStore); diff --git a/projects/www/src/app/pages/guide/signals/signal-store/testing.md b/projects/www/src/app/pages/guide/signals/signal-store/testing.md index 898e36145e..e6c973a626 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/testing.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/testing.md @@ -440,9 +440,9 @@ describe('MoviesStore', () => { -It is important to account for the glitch-free effect when using Signals. The `rxMethod` relies on `effect`, which may need to be triggered manually through `TestBed.flushEffects()`. +It is important to account for the glitch-free effect when using Signals. The `rxMethod` relies on `effect`, which may need to be triggered manually through `TestBed.tick()`. -If the mocked `MovieService` operates synchronously, the following test fails unless `TestBed.flushEffects()` is called. +If the mocked `MovieService` operates synchronously, the following test fails unless `TestBed.tick()` is called. @@ -473,11 +473,11 @@ describe('MoviesStore', () => { const store = TestBed.inject(MoviesStore); const studio = signal('Warner Bros'); store.load(studio); - TestBed.flushEffects(); // required + TestBed.tick(); // required expect(store.movies()).toEqual([{ id: 1, name: 'Harry Potter' }]); studio.set('Universal'); - TestBed.flushEffects(); // required + TestBed.tick(); // required expect(store.movies()).toEqual([ { id: 2, name: 'Jurassic Park' }, ]); diff --git a/projects/www/src/app/pages/guide/store/action-groups.md b/projects/www/src/app/pages/guide/store/action-groups.md index 5eda1de92b..2d83a3590e 100644 --- a/projects/www/src/app/pages/guide/store/action-groups.md +++ b/projects/www/src/app/pages/guide/store/action-groups.md @@ -2,7 +2,12 @@
- +
diff --git a/projects/www/src/app/pages/guide/store/actions.md b/projects/www/src/app/pages/guide/store/actions.md index fa642b70ae..8616ef2740 100644 --- a/projects/www/src/app/pages/guide/store/actions.md +++ b/projects/www/src/app/pages/guide/store/actions.md @@ -76,7 +76,7 @@ Use the action creator to return the `Action` when dispatching. ```ts - onSubmit(username: string, password: string) { +onSubmit(username: string, password: string) { store.dispatch(login({ username: username, password: password })); } ``` diff --git a/projects/www/src/app/pages/guide/store/index.md b/projects/www/src/app/pages/guide/store/index.md index 90d292a827..ab4a164901 100644 --- a/projects/www/src/app/pages/guide/store/index.md +++ b/projects/www/src/app/pages/guide/store/index.md @@ -150,14 +150,14 @@ export class MyCounterComponent { -```ts - +```html + -
Current Count: {{ count$ | async }}
+
Current Count: {{ count$ | async }}
- + - + ```
diff --git a/projects/www/src/app/pages/guide/store/reducers.md b/projects/www/src/app/pages/guide/store/reducers.md index 404c152f0d..6f1eabcb20 100644 --- a/projects/www/src/app/pages/guide/store/reducers.md +++ b/projects/www/src/app/pages/guide/store/reducers.md @@ -356,7 +356,7 @@ export class AppModule {} ```
- + Note: Similarly, if you are using effects, you will need to register both `EffectsModule.forRoot([...])` and `provideEffects([...])`. For more info, see [Effects](guide/effects). ## Next Steps diff --git a/projects/www/src/app/pages/guide/store/testing.md b/projects/www/src/app/pages/guide/store/testing.md index 4596b2b1c1..75e048e921 100644 --- a/projects/www/src/app/pages/guide/store/testing.md +++ b/projects/www/src/app/pages/guide/store/testing.md @@ -73,13 +73,93 @@ Usage: -`ts` +```ts +import { createSelector, createFeatureSelector } from '@ngrx/store'; +import { Book } from '../book-list/books.model'; + +export const selectBooks = + createFeatureSelector>('books'); + +export const selectCollectionState = + createFeatureSelector>('collection'); + +export const selectBookCollection = createSelector( + selectBooks, + selectCollectionState, + (books, collection) => { + return collection.map( + (id) => books.find((book) => book.id === id)! + ); + } +); +``` -`ts` +```ts +mockBooksSelector = store.overrideSelector(selectBooks, [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, +]); + +mockBookCollectionSelector = store.overrideSelector( + selectBookCollection, + [] +); + +fixture.detectChanges(); +spyOn(store, 'dispatch').and.callFake(() => {}); + +it('should update the UI when the store changes', () => { + mockBooksSelector.setResult([ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + ]); + + mockBookCollectionSelector.setResult([ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + ]); + + store.refreshState(); + fixture.detectChanges(); + + expect( + fixture.debugElement.queryAll(By.css('.book-list .book-item')) + .length + ).toBe(2); + + expect( + fixture.debugElement.queryAll( + By.css('.book-collection .book-item') + ).length + ).toBe(1); +}); +``` @@ -89,7 +169,53 @@ You can reset selectors by calling the `MockStore.resetSelectors()` method in th -`ts` +```ts +describe('AppComponent reset selectors', () => { + let store: MockStore; + + afterEach(() => { + store?.resetSelectors(); + }); + + it('should return the mocked value', (done: any) => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: selectBooks, + value: [ + { + id: 'mockedId', + volumeInfo: { + title: 'Mocked Title', + authors: ['Mocked Author'], + }, + }, + ], + }, + ], + }), + ], + }); + + store = TestBed.inject(MockStore); + + store.select(selectBooks).subscribe((mockBooks) => { + expect(mockBooks).toEqual([ + { + id: 'mockedId', + volumeInfo: { + title: 'Mocked Title', + authors: ['Mocked Author'], + }, + }, + ]); + done(); + }); + }); +}); +``` @@ -100,11 +226,79 @@ Try the . An integration test should verify that the `Store` coherently works together with our components and services that inject `Store`. An integration test will not mock the store or individual selectors, as unit tests do, but will instead integrate a `Store` by using `StoreModule.forRoot` in your `TestBed` configuration. Here is part of an integration test for the `AppComponent` introduced in the [walkthrough](guide/store/walkthrough). + +```ts +TestBed.configureTestingModule({ + declarations: [ + AppComponent, + BookListComponent, + BookCollectionComponent, + ], + imports: [ + HttpClientTestingModule, + StoreModule.forRoot({ + books: booksReducer, + collection: collectionReducer, + }), + ], + providers: [GoogleBooksService], +}).compileComponents(); + +fixture = TestBed.createComponent(AppComponent); +component = fixture.debugElement.componentInstance; + +fixture.detectChanges(); +``` + The integration test sets up the dependent `Store` by importing the `StoreModule`. In this part of the example, we assert that clicking the `add` button dispatches the corresponding action and is correctly emitted by the `collection` selector. + +```ts +describe('buttons should work as expected', () => { + it('should add to collection when add button is clicked and remove from collection when remove button is clicked', () => { + const addButton = getBookList()[1].query( + By.css('[data-test=add-button]') + ); + + click(addButton); + expect(getBookTitle(getCollection()[0])).toBe('Second Title'); + + const removeButton = getCollection()[0].query( + By.css('[data-test=remove-button]') + ); + click(removeButton); + + expect(getCollection().length).toBe(0); + }); +}); + +//functions used in the above test +function getCollection() { + return fixture.debugElement.queryAll( + By.css('.book-collection .book-item') + ); +} + +function getBookList() { + return fixture.debugElement.queryAll( + By.css('.book-list .book-item') + ); +} + +function getBookTitle(element) { + return element.query(By.css('p')).nativeElement.textContent; +} + +function click(element) { + const el: HTMLElement = element.nativeElement; + el.click(); + fixture.detectChanges(); +} +``` + ### Testing selectors @@ -112,6 +306,49 @@ The integration test sets up the dependent `Store` by importing the `StoreModule You can use the projector function used by the selector by accessing the `.projector` property. The following example tests the `books` selector from the [walkthrough](guide/store/walkthrough). + +```ts +import { selectBooks, selectBookCollection } from './books.selectors'; +import { AppState } from './app.state'; + +describe('Selectors', () => { + const initialState: AppState = { + books: [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + ], + collection: ['firstId'], + }; + + it('should select the book list', () => { + const result = selectBooks.projector(initialState.books); + expect(result.length).toEqual(2); + expect(result[1].id).toEqual('secondId'); + }); + + it('should select the book collection', () => { + const result = selectBookCollection.projector( + initialState.books, + initialState.collection + ); + expect(result.length).toEqual(1); + expect(result[0].id).toEqual('firstId'); + }); +}); +``` + ### Testing reducers @@ -120,7 +357,45 @@ The following example tests the `booksReducer` from the [walkthrough](guide/stor -`ts` +```ts +import * as fromReducer from './books.reducer'; +import { retrievedBookList } from './books.actions'; +import { Book } from '../book-list/books.model'; + +describe('BooksReducer', () => { + describe('unknown action', () => { + it('should return the default state', () => { + const { initialState } = fromReducer; + const action = { + type: 'Unknown', + }; + const state = fromReducer.booksReducer(initialState, action); + + expect(state).toBe(initialState); + }); + }); + + describe('retrievedBookList action', () => { + it('should retrieve all books and update the state in an immutable way', () => { + const { initialState } = fromReducer; + const newState: Array = [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + ]; + const action = retrievedBookList({ Book: newState }); + const state = fromReducer.booksReducer(initialState, action); + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); +}); +``` diff --git a/projects/www/src/app/pages/guide/store/walkthrough.md b/projects/www/src/app/pages/guide/store/walkthrough.md index 49e8b167de..083b7ad42c 100644 --- a/projects/www/src/app/pages/guide/store/walkthrough.md +++ b/projects/www/src/app/pages/guide/store/walkthrough.md @@ -1,12 +1,12 @@ # Walkthrough -The following example more extensively utilizes the key concepts of store to manage the state of book list, and how the user can add a book to and remove it from their collection within an Angular component. +The following example more extensively utilizes the key concepts of store to manage the state of book list, and how the user can add a book to and remove it from their collection within an Angular component. Try the . ## Tutorial 1. Generate a new project using StackBlitz and create a folder named `book-list` inside the `app` folder. This folder is used to hold the book list component later in the tutorial. For now, let's start with adding a file named `books.model.ts` to reference different aspects of a book in the book list. - + ```ts export interface Book { @@ -22,7 +22,7 @@ export interface Book { 2. Right click on the `app` folder to create a state management folder `state`. Within the new folder, create a new file `books.actions.ts` to describe the book actions. Book actions include the book list retrieval, and the add and remove book actions. - + ```ts import { createActionGroup, props } from '@ngrx/store'; @@ -48,7 +48,7 @@ export const BooksApiActions = createActionGroup({ 3. Right click on the `state` folder and create a new file labeled `books.reducer.ts`. Within this file, define a reducer function to handle the retrieval of the book list from the state and consequently, update the state. - + ```ts import { createReducer, on } from '@ngrx/store'; @@ -68,7 +68,7 @@ export const booksReducer = createReducer( 4. Create another file named `collection.reducer.ts` in the `state` folder to handle actions that alter the user's book collection. Define a reducer function that handles the add action by appending the book's ID to the collection, including a condition to avoid duplicate book IDs. Define the same reducer to handle the remove action by filtering the collection array with the book ID. - + ```ts import { createReducer, on } from '@ngrx/store'; @@ -91,42 +91,44 @@ export const collectionReducer = createReducer( -5. Import the `provideStore` from `@ngrx/store` and the `books.reducer` and `collection.reducer` file. +5. Import the `StoreModule` from `@ngrx/store` and the `books.reducer` and `collection.reducer` file. - + ```ts -import { provideStore } from '@ngrx/store'; - +import { HttpClientModule } from '@angular/common/http'; import { booksReducer } from './state/books.reducer'; import { collectionReducer } from './state/collection.reducer'; +import { StoreModule } from '@ngrx/store'; ``` -6. Add the `provideStore` function in the `providers` array of your `app.config.ts` with an object containing the `books` and `booksReducer`, as well as the `collection` and `collectionReducer` that manage the state of the book list and the collection. The `provideStore` function registers the global providers needed to access the `Store` throughout your application. +6. Add the `StoreModule.forRoot` function in the `imports` array of your `AppModule` with an object containing the `books` and `booksReducer`, as well as the `collection` and `collectionReducer` that manage the state of the book list and the collection. The `StoreModule.forRoot()` method registers the global providers needed to access the `Store` throughout your application. - + ```ts -import { ApplicationConfig } from '@angular/core'; - -export const appConfig: ApplicationConfig = { - providers: [ - // ..other providers - provideStore({ +@NgModule({ + imports: [ + BrowserModule, + StoreModule.forRoot({ books: booksReducer, collection: collectionReducer, }), + HttpClientModule, ], -}; + declarations: [AppComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} ``` 7. Create the book list and collection selectors to ensure we get the correct information from the store. As you can see, the `selectBookCollection` selector combines two other selectors in order to build its return value. - + ```ts import { createSelector, createFeatureSelector } from '@ngrx/store'; @@ -153,7 +155,7 @@ export const selectBookCollection = createSelector( 8. In the `book-list` folder, we want to have a service that fetches the data needed for the book list from an API. Create a file in the `book-list` folder named `books.service.ts`, which will call the Google Books API and return a list of books. - + ```ts import { HttpClient } from '@angular/common/http'; @@ -181,26 +183,29 @@ export class GoogleBooksService { 9. In the same folder (`book-list`), create the `BookListComponent` with the following template. Update the `BookListComponent` class to dispatch the `add` event. - - -```angular-ts -@for(book of books; track book) { -
-

{{book.volumeInfo.title}}

by {{book.volumeInfo.authors}} - -
-} + + +```html +
+

{{book.volumeInfo.title}}

+ by {{book.volumeInfo.authors}} + +
```
- + ```ts -import { Component, input, output } from '@angular/core'; +import { + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; import { Book } from './books.model'; @Component({ @@ -209,8 +214,8 @@ import { Book } from './books.model'; styleUrls: ['./book-list.component.css'], }) export class BookListComponent { - books = input>([]); - add = output(); + @Input() books: ReadonlyArray = []; + @Output() add = new EventEmitter(); } ``` @@ -218,26 +223,29 @@ export class BookListComponent { 10. Create a new _Component_ named `book-collection` in the `app` folder. Update the `BookCollectionComponent` template and class. - - -```angular-ts -@for(book of books; track book) { -
-

{{book.volumeInfo.title}}

by {{book.volumeInfo.authors}} - -
-} + + +```html +
+

{{book.volumeInfo.title}}

+ by {{book.volumeInfo.authors}} + +
```
- + ```ts -import { Component, input, output } from '@angular/core'; +import { + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; import { Book } from '../book-list/books.model'; @Component({ @@ -246,32 +254,29 @@ import { Book } from '../book-list/books.model'; styleUrls: ['./book-collection.component.css'], }) export class BookCollectionComponent { - books = input>([]); - add = output(); + @Input() books: ReadonlyArray = []; + @Output() remove = new EventEmitter(); } ``` -11. Add `BookListComponent` and `BookCollectionComponent` to your `AppComponent` template, and to your imports in `app.component.ts` as well. +11. Add `BookListComponent` and `BookCollectionComponent` to your `AppComponent` template, and to your declarations (along with their top level import statements) in `app.module.ts` as well. - + ```html

Books

- - +>

My Collection

- @@ -279,27 +284,45 @@ export class BookCollectionComponent {
+ + ```ts -import { Component, input, output } from '@angular/core'; -import { Book } from '../book-list/books.model'; +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; -@Component({ - selector: 'app-book-collection', - templateUrl: './book-collection.component.html', - styleUrls: ['./book-collection.component.css'], - imports: [BookListComponent, BookCollectionComponent], +import { HttpClientModule } from '@angular/common/http'; +import { booksReducer } from './state/books.reducer'; +import { collectionReducer } from './state/collection.reducer'; +import { StoreModule } from '@ngrx/store'; + +import { AppComponent } from './app.component'; +import { BookListComponent } from './book-list/book-list.component'; +import { BookCollectionComponent } from './book-collection/book-collection.component'; + +@NgModule({ + imports: [ + BrowserModule, + StoreModule.forRoot({ + books: booksReducer, + collection: collectionReducer, + }), + HttpClientModule, + ], + declarations: [ + AppComponent, + BookListComponent, + BookCollectionComponent, + ], + bootstrap: [AppComponent], }) -export class BookCollectionComponent { - books = input>([]); - add = output(); -} +export class AppModule {} ``` 12. In the `AppComponent` class, add the selectors and corresponding actions to dispatch on `add` or `remove` method calls. Then subscribe to the Google Books API in order to update the state. (This should probably be handled by NgRx Effects, which you can read about [here](guide/effects). For the sake of this demo, NgRx Effects is not being included). - + ```ts import { Component, OnInit } from '@angular/core'; @@ -317,8 +340,8 @@ import { GoogleBooksService } from './book-list/books.service'; templateUrl: './app.component.html', }) export class AppComponent implements OnInit { - books = this.store.selectSignal(selectBooks); - bookCollection = this.store.selectSignal(selectBookCollection); + books$ = this.store.select(selectBooks); + bookCollection$ = this.store.select(selectBookCollection); onAdd(bookId: string) { this.store.dispatch(BooksActions.addBook({ bookId })); diff --git a/projects/www/src/app/pages/guide/style-guide.md b/projects/www/src/app/pages/guide/style-guide.md deleted file mode 100644 index d0cef9c200..0000000000 --- a/projects/www/src/app/pages/guide/style-guide.md +++ /dev/null @@ -1 +0,0 @@ -# Style Guide diff --git a/projects/www/src/app/services/guide-menu.service.ts b/projects/www/src/app/services/guide-menu.service.ts index f31b8f9539..c8d8b0e4cf 100644 --- a/projects/www/src/app/services/guide-menu.service.ts +++ b/projects/www/src/app/services/guide-menu.service.ts @@ -75,6 +75,16 @@ export class GuideMenuService { section('SignalStore', [ link('Core Concepts', '/guide/signals/signal-store'), link('Lifecycle Hooks', '/guide/signals/signal-store/lifecycle-hooks'), + link( + 'Custom Store Properties', + '/guide/signals/signal-store/custom-store-properties' + ), + link('Linked State', '/guide/signals/signal-store/linked-state'), + link('State Tracking', '/guide/signals/signal-store/state-tracking'), + link( + 'Private Store Members', + '/guide/signals/signal-store/private-store-members' + ), link( 'Custom Store Features', '/guide/signals/signal-store/custom-store-features' @@ -83,8 +93,12 @@ export class GuideMenuService { 'Entity Management', '/guide/signals/signal-store/entity-management' ), + link('Events', '/guide/signals/signal-store/events'), + link('Testing', '/guide/signals/signal-store/testing'), ]), link('SignalState', '/guide/signals/signal-state'), + link('DeepComputed', '/guide/signals/deep-computed'), + link('SignalMethod', '/guide/signals/signal-method'), link('RxJS Integration', '/guide/signals/rxjs-integration'), link('FAQ', '/guide/signals/faq'), ]), @@ -185,18 +199,21 @@ export class GuideMenuService { section('Developer Resources', [ link('Nightlies', '/guide/nightlies'), section('Migrations', [ - link('v17', '/guide/migration/v17'), - link('v16', '/guide/migration/v16'), - link('v15', '/guide/migration/v15'), - link('v14', '/guide/migration/v14'), - link('v13', '/guide/migration/v13'), - link('v12', '/guide/migration/v12'), - link('v11', '/guide/migration/v11'), - link('v10', '/guide/migration/v10'), - link('v9', '/guide/migration/v9'), - link('v8', '/guide/migration/v8'), - link('v7', '/guide/migration/v7'), - link('v4', '/guide/migration/v4'), + link('V20', '/guide/migration/v20'), + link('V19', '/guide/migration/v19'), + link('V18', '/guide/migration/v18'), + link('V17', '/guide/migration/v17'), + link('V16', '/guide/migration/v16'), + link('V15', '/guide/migration/v15'), + link('V14', '/guide/migration/v14'), + link('V13', '/guide/migration/v13'), + link('V12', '/guide/migration/v12'), + link('V11', '/guide/migration/v11'), + link('V10', '/guide/migration/v10'), + link('V9', '/guide/migration/v9'), + link('V8', '/guide/migration/v8'), + link('V7', '/guide/migration/v7'), + link('V4', '/guide/migration/v4'), ]), ]), ]); diff --git a/projects/www/src/styles.scss b/projects/www/src/styles.scss index b7bdb4a06f..de28cfdc49 100644 --- a/projects/www/src/styles.scss +++ b/projects/www/src/styles.scss @@ -6,15 +6,15 @@ html { color-scheme: dark; + /* Prevent font size inflation */ + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; @include mat.theme(( color: mat.$violet-palette, typography: Roboto, density: 0 )); - /* Prevent font size inflation */ - -moz-text-size-adjust: none; - -webkit-text-size-adjust: none; - text-size-adjust: none; } *, @@ -188,4 +188,4 @@ analog-markdown-route > div { width: 100%; height: 100%; } -} \ No newline at end of file +}