Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -17,7 +15,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 @@ -36,7 +34,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 @@ -64,7 +62,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 @@ -93,8 +91,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,15 +5,13 @@ import {
EffectRef,
inject,
Injector,
isSignal,
Signal,
untracked,
} from '@angular/core';

declare const ngDevMode: unknown;

export type SignalMethod<Input> = ((
input: Input | Signal<Input>,
input: Input | (() => Input),
config?: { injector?: Injector }
) => EffectRef) &
EffectRef;
Expand All @@ -30,10 +28,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 @@ -90,3 +88,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