Skip to content
29 changes: 29 additions & 0 deletions modules/signals/rxjs-interop/spec/rx-method.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(
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<void>();
Expand Down
12 changes: 5 additions & 7 deletions modules/signals/rxjs-interop/src/rx-method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import {
effect,
inject,
Injector,
isSignal,
Signal,
untracked,
} from '@angular/core';
import { isObservable, noop, Observable, Subject } from 'rxjs';
Expand All @@ -15,7 +13,7 @@ type RxMethodRef = {
};

export type RxMethod<Input> = ((
input: Input | Signal<Input> | Observable<Input>,
input: Input | (() => Input) | Observable<Input>,
config?: { injector?: Injector }
) => RxMethodRef) &
RxMethodRef;
Expand All @@ -34,7 +32,7 @@ export function rxMethod<Input>(
sourceInjector.get(DestroyRef).onDestroy(() => sourceSub.unsubscribe());

const rxMethodFn = (
input: Input | Signal<Input> | Observable<Input>,
input: Input | (() => Input) | Observable<Input>,
config?: { injector?: Injector }
): RxMethodRef => {
if (isStatic(input)) {
Expand Down Expand Up @@ -62,7 +60,7 @@ export function rxMethod<Input>(
const instanceInjector =
config?.injector ?? callerInjector ?? sourceInjector;

if (isSignal(input)) {
if (typeof input === 'function') {
const watcher = effect(
() => {
const value = input();
Expand Down Expand Up @@ -91,8 +89,8 @@ export function rxMethod<Input>(
return rxMethodFn;
}

function isStatic<T>(value: T | Signal<T> | Observable<T>): value is T {
return !isSignal(value) && !isObservable(value);
function isStatic<T>(value: T | (() => T) | Observable<T>): value is T {
return typeof value !== 'function' && !isObservable(value);
}

function getCallerInjector(): Injector | undefined {
Expand Down
22 changes: 22 additions & 0 deletions modules/signals/spec/signal-method.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>(() => void true)).toThrowError();
});
Expand Down
12 changes: 7 additions & 5 deletions modules/signals/src/signal-method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@ import {
EffectRef,
inject,
Injector,
isSignal,
Signal,
untracked,
} from '@angular/core';

export type SignalMethod<Input> = ((
input: Input | Signal<Input>,
input: Input | (() => Input),
config?: { injector?: Injector }
) => EffectRef) &
EffectRef;
Expand All @@ -28,10 +26,10 @@ export function signalMethod<Input>(
const sourceInjector = config?.injector ?? inject(Injector);

const signalMethodFn = (
input: Input | Signal<Input>,
input: Input | (() => Input),
config?: { injector?: Injector }
): EffectRef => {
if (isSignal(input)) {
if (isReactiveComputation(input)) {
const callerInjector = getCallerInjector();
if (
typeof ngDevMode !== 'undefined' &&
Expand Down Expand Up @@ -88,3 +86,7 @@ function getCallerInjector(): Injector | undefined {
return undefined;
}
}

function isReactiveComputation<T>(value: T | (() => T)): value is () => T {
return typeof value === 'function';
}
39 changes: 35 additions & 4 deletions projects/www/src/app/pages/guide/signals/rxjs-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using the term "reactive computation" instead of auto-tracking functions because that's what the Angular team calls it in linkedSignal.

Input can be typed by providing a generic argument to the `rxMethod` function.

<ngrx-code-example>
Expand All @@ -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<number> | Observable<number>`.
// of type `number | (() => number) | Observable<number>`.
readonly logDoubledNumber = rxMethod<number>(
// 👇 RxJS operators are chained together using the `pipe` function.
pipe(
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<number>(
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
Expand Down Expand Up @@ -268,7 +299,7 @@ export class Numbers implements OnInit {

<ngrx-docs-alert type="inform">

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.

</ngrx-docs-alert>

Expand Down
34 changes: 29 additions & 5 deletions projects/www/src/app/pages/guide/signals/signal-method.md
Original file line number Diff line number Diff line change
@@ -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:

<ngrx-code-example>

Expand All @@ -13,7 +13,7 @@ import { signalMethod } from '@ngrx/signals';
})
export class Numbers {
// 👇 This method will have an input argument
// of type `number | Signal<number>`.
// of type `number | (() => number)`.
readonly logDoubledNumber = signalMethod<number>((num) => {
const double = num * 2;
console.log(double);
Expand All @@ -23,7 +23,7 @@ export class Numbers {

</ngrx-code-example>

`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({
Expand All @@ -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<number>((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.
Expand Down Expand Up @@ -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`

Expand Down