Skip to content

Commit eea69d7

Browse files
feat(signals): allow computation fn to be provided to signalMethod and rxMethod (#4996)
Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com> Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Closes #4986
1 parent c99f745 commit eea69d7

File tree

6 files changed

+126
-21
lines changed

6 files changed

+126
-21
lines changed

modules/signals/rxjs-interop/spec/rx-method.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,35 @@ describe('rxMethod', () => {
6565
expect(results[1]).toBe(10);
6666
}));
6767

68+
it('runs with a computation function', () => {
69+
TestBed.runInInjectionContext(() => {
70+
const results: number[] = [];
71+
const method = rxMethod<number>(
72+
pipe(tap((value) => results.push(value)))
73+
);
74+
75+
const a = signal(1);
76+
const b = signal(1);
77+
const multiplier = () => a() * b();
78+
79+
method(multiplier);
80+
expect(results.length).toBe(0);
81+
82+
TestBed.tick();
83+
expect(results[0]).toBe(1);
84+
85+
a.set(5);
86+
expect(results.length).toBe(1);
87+
88+
TestBed.tick();
89+
expect(results[1]).toBe(5);
90+
91+
b.set(2);
92+
TestBed.tick();
93+
expect(results[2]).toBe(10);
94+
});
95+
});
96+
6897
it('runs with void input', () => {
6998
const results: number[] = [];
7099
const subject$ = new Subject<void>();

modules/signals/rxjs-interop/src/rx-method.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import {
44
effect,
55
inject,
66
Injector,
7-
isSignal,
8-
Signal,
97
untracked,
108
} from '@angular/core';
119
import { isObservable, noop, Observable, Subject } from 'rxjs';
@@ -15,7 +13,7 @@ type RxMethodRef = {
1513
};
1614

1715
export type RxMethod<Input> = ((
18-
input: Input | Signal<Input> | Observable<Input>,
16+
input: Input | (() => Input) | Observable<Input>,
1917
config?: { injector?: Injector }
2018
) => RxMethodRef) &
2119
RxMethodRef;
@@ -34,7 +32,7 @@ export function rxMethod<Input>(
3432
sourceInjector.get(DestroyRef).onDestroy(() => sourceSub.unsubscribe());
3533

3634
const rxMethodFn = (
37-
input: Input | Signal<Input> | Observable<Input>,
35+
input: Input | (() => Input) | Observable<Input>,
3836
config?: { injector?: Injector }
3937
): RxMethodRef => {
4038
if (isStatic(input)) {
@@ -62,7 +60,7 @@ export function rxMethod<Input>(
6260
const instanceInjector =
6361
config?.injector ?? callerInjector ?? sourceInjector;
6462

65-
if (isSignal(input)) {
63+
if (typeof input === 'function') {
6664
const watcher = effect(
6765
() => {
6866
const value = input();
@@ -91,8 +89,8 @@ export function rxMethod<Input>(
9189
return rxMethodFn;
9290
}
9391

94-
function isStatic<T>(value: T | Signal<T> | Observable<T>): value is T {
95-
return !isSignal(value) && !isObservable(value);
92+
function isStatic<T>(value: T | (() => T) | Observable<T>): value is T {
93+
return typeof value !== 'function' && !isObservable(value);
9694
}
9795

9896
function getCallerInjector(): Injector | undefined {

modules/signals/spec/signal-method.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ describe('signalMethod', () => {
3939
expect(a).toBe(4);
4040
});
4141

42+
it('processes a computation function', () => {
43+
const a = signal(1);
44+
const b = signal(1);
45+
const add = () => a() + b();
46+
let sum = 0;
47+
const adder = createAdder((value) => (sum += value));
48+
49+
adder(add);
50+
expect(sum).toBe(0);
51+
52+
TestBed.tick();
53+
expect(sum).toBe(2);
54+
55+
a.set(2);
56+
b.set(2);
57+
TestBed.tick();
58+
expect(sum).toBe(6);
59+
60+
TestBed.tick();
61+
expect(sum).toBe(6);
62+
});
63+
4264
it('throws if is a not created in an injection context', () => {
4365
expect(() => signalMethod<void>(() => void true)).toThrowError();
4466
});

modules/signals/src/signal-method.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@ import {
55
EffectRef,
66
inject,
77
Injector,
8-
isSignal,
9-
Signal,
108
untracked,
119
} from '@angular/core';
1210

1311
export type SignalMethod<Input> = ((
14-
input: Input | Signal<Input>,
12+
input: Input | (() => Input),
1513
config?: { injector?: Injector }
1614
) => EffectRef) &
1715
EffectRef;
@@ -28,10 +26,10 @@ export function signalMethod<Input>(
2826
const sourceInjector = config?.injector ?? inject(Injector);
2927

3028
const signalMethodFn = (
31-
input: Input | Signal<Input>,
29+
input: Input | (() => Input),
3230
config?: { injector?: Injector }
3331
): EffectRef => {
34-
if (isSignal(input)) {
32+
if (isReactiveComputation(input)) {
3533
const callerInjector = getCallerInjector();
3634
if (
3735
typeof ngDevMode !== 'undefined' &&
@@ -88,3 +86,7 @@ function getCallerInjector(): Injector | undefined {
8886
return undefined;
8987
}
9088
}
89+
90+
function isReactiveComputation<T>(value: T | (() => T)): value is () => T {
91+
return typeof value === 'function';
92+
}

projects/www/src/app/pages/guide/signals/rxjs-integration.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ RxJS is still a major part of NgRx and the Angular ecosystem, and the `@ngrx/sig
66

77
The `rxMethod` is a standalone factory function designed for managing side effects by utilizing RxJS APIs.
88
It takes a chain of RxJS operators as input and returns a reactive method.
9-
The reactive method can accept a static value, signal, or observable as an input argument.
9+
The reactive method can accept a static value, a signal, a computation function, or an observable as an input argument.
1010
Input can be typed by providing a generic argument to the `rxMethod` function.
1111

1212
<ngrx-code-example>
@@ -21,7 +21,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
2121
})
2222
export class Numbers {
2323
// 👇 This reactive method will have an input argument
24-
// of type `number | Signal<number> | Observable<number>`.
24+
// of type `number | (() => number) | Observable<number>`.
2525
readonly logDoubledNumber = rxMethod<number>(
2626
// 👇 RxJS operators are chained together using the `pipe` function.
2727
pipe(
@@ -63,7 +63,7 @@ export class Numbers {
6363
}
6464
```
6565

66-
When a reactive method is called with a signal, the reactive chain is executed every time the signal value changes.
66+
When a reactive method is called with a signal the reactive chain is executed every time the signal value changes.
6767

6868
```ts
6969
import { Component, signal } from '@angular/core';
@@ -92,6 +92,37 @@ export class Numbers {
9292
}
9393
```
9494

95+
In addition to providing a signal, it is also possible to provide a computation function and combine multiple signals within it.
96+
97+
```ts
98+
import { Component, signal } from '@angular/core';
99+
import { map, pipe, tap } from 'rxjs';
100+
import { rxMethod } from '@ngrx/signals/rxjs-interop';
101+
102+
@Component({
103+
/* ... */
104+
})
105+
export class Numbers {
106+
readonly logSum = rxMethod<{ a: number; b: number }>(
107+
pipe(
108+
map(({ a, b }) => a + b),
109+
tap(console.log)
110+
)
111+
);
112+
113+
constructor() {
114+
const num1 = signal(10);
115+
const num2 = signal(20);
116+
117+
this.logSum(() => ({ a: num1(), b: num2() }));
118+
// console output: 30
119+
120+
setTimeout(() => b.set(30), 3_000);
121+
// console output after 3 seconds: 50
122+
}
123+
}
124+
```
125+
95126
When a reactive method is called with an observable, the reactive chain is executed every time the observable emits a new value.
96127

97128
```ts
@@ -268,7 +299,7 @@ export class Numbers implements OnInit {
268299

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

271-
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.
302+
If the injector is not provided when calling the reactive method with a signal, a computation function, or an observable outside the injection context, a warning message about a potential memory leak is displayed in development mode.
272303

273304
</ngrx-docs-alert>
274305

projects/www/src/app/pages/guide/signals/signal-method.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SignalMethod
22

3-
`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:
3+
`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:
44

55
<ngrx-code-example>
66

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

2424
</ngrx-code-example>
2525

26-
`logDoubledNumber` can be called with a static value of type `number`, or a Signal of type `number`:
26+
`logDoubledNumber` can be called with a static value of type `number` or a `Signal<number>`.
2727

2828
```ts
2929
@Component({
@@ -49,6 +49,29 @@ export class Numbers {
4949
}
5050
```
5151

52+
In addition to providing a Signal, it is also possible to provide a computation function and combine multiple Signals within it.
53+
54+
```ts
55+
@Component({
56+
/* ... */
57+
})
58+
export class Numbers {
59+
readonly logSum = signalMethod<{ a: number; b: number }>(
60+
({ a, b }) => console.log(a + b)
61+
);
62+
63+
constructor() {
64+
const num1 = signal(1);
65+
const num2 = signal(2);
66+
this.logSum(() => ({ a: num1(), b: num2() }));
67+
// console output: 3
68+
69+
setTimeout(() => num1.set(3), 3_000);
70+
// console output after 3 seconds: 5
71+
}
72+
}
73+
```
74+
5275
## Automatic Cleanup
5376

5477
`signalMethod` uses an `effect` internally to track the Signal changes.
@@ -184,9 +207,9 @@ export class Numbers {
184207

185208
However, `signalMethod` offers three distinctive advantages over `effect`:
186209

187-
- **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.
210+
- **Flexible Input**: The input argument can be a static value, not just a Signal or a computation function. Additionally, the processor function can be called multiple times with different inputs.
188211
- **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.
189-
- **Explicit Tracking**: Only the Signal of the parameter is tracked, while Signals within the "processor function" stay untracked.
212+
- **Explicit Tracking**: Only the provided Signal or Signals used inside the computation function are tracked, while Signals within the "processor function" stay untracked.
190213

191214
## `signalMethod` compared to `rxMethod`
192215

0 commit comments

Comments
 (0)