diff --git a/modules/signals/rxjs-interop/spec/rx-method.spec.ts b/modules/signals/rxjs-interop/spec/rx-method.spec.ts index f7887b3ae0..484971b251 100644 --- a/modules/signals/rxjs-interop/spec/rx-method.spec.ts +++ b/modules/signals/rxjs-interop/spec/rx-method.spec.ts @@ -65,6 +65,35 @@ describe('rxMethod', () => { expect(results[1]).toBe(10); })); + it('runs and tracks a function', () => { + TestBed.runInInjectionContext(() => { + const results: number[] = []; + const method = rxMethod( + pipe(tap((value) => results.push(value))) + ); + + const a = signal(1); + const b = signal(1); + const multplier = () => a() * b(); + + method(multplier); + expect(results.length).toBe(0); + + TestBed.tick(); + expect(results[0]).toBe(1); + + a.set(5); + expect(results.length).toBe(1); + + TestBed.tick(); + expect(results[1]).toBe(5); + + b.set(2); + TestBed.tick(); + expect(results[2]).toBe(10); + }); + }); + it('runs with void input', () => { const results: number[] = []; const subject$ = new Subject(); diff --git a/modules/signals/rxjs-interop/src/rx-method.ts b/modules/signals/rxjs-interop/src/rx-method.ts index 466dd55bd6..cfa6d97502 100644 --- a/modules/signals/rxjs-interop/src/rx-method.ts +++ b/modules/signals/rxjs-interop/src/rx-method.ts @@ -4,8 +4,6 @@ import { effect, inject, Injector, - isSignal, - Signal, untracked, } from '@angular/core'; import { isObservable, noop, Observable, Subject } from 'rxjs'; @@ -17,7 +15,7 @@ type RxMethodRef = { }; export type RxMethod = (( - input: Input | Signal | Observable, + input: Input | (() => Input) | Observable, config?: { injector?: Injector } ) => RxMethodRef) & RxMethodRef; @@ -36,7 +34,7 @@ export function rxMethod( sourceInjector.get(DestroyRef).onDestroy(() => sourceSub.unsubscribe()); const rxMethodFn = ( - input: Input | Signal | Observable, + input: Input | (() => Input) | Observable, config?: { injector?: Injector } ): RxMethodRef => { if (isStatic(input)) { @@ -64,7 +62,7 @@ export function rxMethod( const instanceInjector = config?.injector ?? callerInjector ?? sourceInjector; - if (isSignal(input)) { + if (typeof input === 'function') { const watcher = effect( () => { const value = input(); @@ -93,8 +91,8 @@ export function rxMethod( return rxMethodFn; } -function isStatic(value: T | Signal | Observable): value is T { - return !isSignal(value) && !isObservable(value); +function isStatic(value: T | (() => T) | Observable): value is T { + return typeof value !== 'function' && !isObservable(value); } function getCallerInjector(): Injector | undefined { diff --git a/modules/signals/spec/signal-method.spec.ts b/modules/signals/spec/signal-method.spec.ts index eff294a8ca..2091dee26d 100644 --- a/modules/signals/spec/signal-method.spec.ts +++ b/modules/signals/spec/signal-method.spec.ts @@ -39,6 +39,28 @@ describe('signalMethod', () => { expect(a).toBe(4); }); + it('tracks signals within a function input automatically', () => { + const a = signal(1); + const b = signal(1); + const add = () => a() + b(); + let sum = 0; + const adder = createAdder((value) => (sum += value)); + + adder(add); + expect(sum).toBe(0); + + TestBed.tick(); + expect(sum).toBe(2); + + a.set(2); + b.set(2); + TestBed.tick(); + expect(sum).toBe(6); + + TestBed.tick(); + expect(sum).toBe(6); + }); + it('throws if is a not created in an injection context', () => { expect(() => signalMethod(() => void true)).toThrowError(); }); diff --git a/modules/signals/src/signal-method.ts b/modules/signals/src/signal-method.ts index ae1a9c4b48..e0d5e6a88b 100644 --- a/modules/signals/src/signal-method.ts +++ b/modules/signals/src/signal-method.ts @@ -5,15 +5,13 @@ import { EffectRef, inject, Injector, - isSignal, - Signal, untracked, } from '@angular/core'; declare const ngDevMode: unknown; export type SignalMethod = (( - input: Input | Signal, + input: Input | (() => Input), config?: { injector?: Injector } ) => EffectRef) & EffectRef; @@ -30,10 +28,10 @@ export function signalMethod( const sourceInjector = config?.injector ?? inject(Injector); const signalMethodFn = ( - input: Input | Signal, + input: Input | (() => Input), config?: { injector?: Injector } ): EffectRef => { - if (isSignal(input)) { + if (isReactiveComputation(input)) { const callerInjector = getCallerInjector(); if ( typeof ngDevMode !== 'undefined' && @@ -90,3 +88,7 @@ function getCallerInjector(): Injector | undefined { return undefined; } } + +function isReactiveComputation(value: T | (() => T)): value is () => T { + return typeof value === 'function'; +} 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 2abbec0239..384b357168 100644 --- a/projects/www/src/app/pages/guide/signals/rxjs-integration.md +++ b/projects/www/src/app/pages/guide/signals/rxjs-integration.md @@ -6,7 +6,7 @@ RxJS is still a major part of NgRx and the Angular ecosystem, and the `@ngrx/sig The `rxMethod` is a standalone factory function designed for managing side effects by utilizing RxJS APIs. It takes a chain of RxJS operators as input and returns a reactive method. -The reactive method can accept a static value, signal, or observable as an input argument. +The reactive method can accept a static value, a reactive computation (like a Signal), or observable as an input argument. Input can be typed by providing a generic argument to the `rxMethod` function. @@ -21,7 +21,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; }) export class Numbers { // 👇 This reactive method will have an input argument - // of type `number | Signal | Observable`. + // of type `number | (() => number) | Observable`. readonly logDoubledNumber = rxMethod( // 👇 RxJS operators are chained together using the `pipe` function. pipe( @@ -63,7 +63,7 @@ export class Numbers { } ``` -When a reactive method is called with a signal, the reactive chain is executed every time the signal value changes. +When a reactive method is called with a reactive computation (like a Signal) the reactive chain is executed every time the signal value changes. ```ts import { Component, signal } from '@angular/core'; @@ -92,6 +92,37 @@ export class Numbers { } ``` +And here the same example with a more generic reactive computation. + +```ts +import { Component, signal } from '@angular/core'; +import { map, pipe, tap } from 'rxjs'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; + +@Component({ + /* ... */ +}) +export class Numbers { + readonly logDoubledNumber = rxMethod( + pipe( + map((num) => num * 2), + tap(console.log) + ) + ); + + constructor() { + const a = signal(5); + const b = signal(2); + + this.logDoubledNumber(() => a() + b()); + // console output: 14 + + setTimeout(() => b.set(10), 3_000); + // console output after 3 seconds: 30 + } +} +``` + When a reactive method is called with an observable, the reactive chain is executed every time the observable emits a new value. ```ts @@ -268,7 +299,7 @@ export class Numbers implements OnInit { -If the injector is not provided when calling the reactive method with a signal or observable outside the injection context, a warning message about a potential memory leak is displayed in development mode. +If the injector is not provided when calling the reactive method with a reactive computation or observable outside the injection context, a warning message about a potential memory leak is displayed in development mode. 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 44e7a2c2e1..37bb59382d 100644 --- a/projects/www/src/app/pages/guide/signals/signal-method.md +++ b/projects/www/src/app/pages/guide/signals/signal-method.md @@ -1,6 +1,6 @@ # SignalMethod -`signalMethod` is a standalone factory function used for managing side effects with Angular signals. It accepts a callback and returns a processor function that can handle either a static value or a signal. The input type can be specified using a generic type argument: +`signalMethod` is a standalone factory function used for managing side effects with Angular signals. It accepts a callback and returns a processor function that can handle either a static value, a signal, or a computation function. The input type can be specified using a generic type argument: @@ -13,7 +13,7 @@ import { signalMethod } from '@ngrx/signals'; }) export class Numbers { // 👇 This method will have an input argument - // of type `number | Signal`. + // of type `number | (() => number)`. readonly logDoubledNumber = signalMethod((num) => { const double = num * 2; console.log(double); @@ -23,7 +23,7 @@ export class Numbers { -`logDoubledNumber` can be called with a static value of type `number`, or a Signal of type `number`: +`logDoubledNumber` can be called with a static value of type `number`, or a reactive computation of type `number`. Since a Signal is a function returning a value, it is also a reactive computation. ```ts @Component({ @@ -49,6 +49,30 @@ export class Numbers { } ``` +Finally, a reactive computation example shows an automatically tracked computation, built from multiple Signals. + +```ts +@Component({ + /* ... */ +}) +export class Numbers { + readonly logDoubledNumber = signalMethod((num) => { + const double = num * 2; + console.log(double); + }); + + constructor() { + const num1 = signal(1); + const num2 = signal(1); + this.logDoubledNumber(() => num1() + num2()); + // console output: 4 + + setTimeout(() => num1.set(2), 3_000); + // console output after 3 seconds: 6 + } +} +``` + ## Automatic Cleanup `signalMethod` uses an `effect` internally to track the Signal changes. @@ -184,9 +208,9 @@ export class Numbers { However, `signalMethod` offers three distinctive advantages over `effect`: -- **Flexible Input**: The input argument can be a static value, not just a signal. Additionally, the processor function can be called multiple times with different inputs. +- **Flexible Input**: The input argument can be a static value, not just a reactive computation. Additionally, the processor function can be called multiple times with different inputs. - **No Injection Context Required**: Unlike an `effect`, which requires an injection context or an Injector, `signalMethod`'s "processor function" can be called without an injection context. -- **Explicit Tracking**: Only the Signal of the parameter is tracked, while Signals within the "processor function" stay untracked. +- **Explicit Tracking**: Only the reactive computation of the parameter is tracked, while Signals within the "processor function" stay untracked. ## `signalMethod` compared to `rxMethod`