Skip to content

Commit a469cbf

Browse files
feat(signals): add delegatedSignal (#5151)
Closes #5121
1 parent 77dd99e commit a469cbf

5 files changed

Lines changed: 239 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { computed, isSignal, signal } from '@angular/core';
2+
import { delegatedSignal } from '../src';
3+
4+
describe('delegatedSignal', () => {
5+
it('updates from source to delegated signal', () => {
6+
const query = signal('');
7+
const limit = signal(10);
8+
9+
const delegated = delegatedSignal({
10+
source: () => ({ query: query(), limit: limit() }),
11+
update: ({ query: q, limit: l }) => {
12+
query.set(q);
13+
limit.set(l);
14+
},
15+
});
16+
17+
expect(delegated()).toEqual({ query: '', limit: 10 });
18+
19+
query.set('ngrx');
20+
expect(delegated()).toEqual({ query: 'ngrx', limit: 10 });
21+
22+
limit.set(25);
23+
expect(delegated()).toEqual({ query: 'ngrx', limit: 25 });
24+
});
25+
26+
it('updates from delegated signal to source', () => {
27+
const query = signal('');
28+
const limit = signal(10);
29+
30+
const delegated = delegatedSignal({
31+
source: () => ({ query: query(), limit: limit() }),
32+
update: ({ query: q, limit: l }) => {
33+
query.set(q);
34+
limit.set(l);
35+
},
36+
});
37+
38+
delegated.set({ query: 'ngrx', limit: 25 });
39+
expect(query()).toBe('ngrx');
40+
expect(limit()).toBe(25);
41+
42+
delegated.update((current) => ({ ...current, query: 'signals' }));
43+
expect(query()).toBe('signals');
44+
expect(limit()).toBe(25);
45+
});
46+
47+
it('asReadonly returns a readonly signal backed by the source computation', () => {
48+
const query = signal('');
49+
const limit = signal(10);
50+
51+
const delegated = delegatedSignal({
52+
source: () => ({ query: query(), limit: limit() }),
53+
update: ({ query: q, limit: l }) => {
54+
query.set(q);
55+
limit.set(l);
56+
},
57+
});
58+
59+
const readonly = delegated.asReadonly();
60+
expect(isSignal(readonly)).toBe(true);
61+
expect('set' in readonly).toBe(false);
62+
expect('update' in readonly).toBe(false);
63+
expect(readonly()).toEqual({ query: '', limit: 10 });
64+
65+
query.set('ngrx');
66+
expect(readonly()).toEqual({ query: 'ngrx', limit: 10 });
67+
});
68+
69+
it('uses the provided equality function', () => {
70+
const query = signal('');
71+
const limit = signal(10);
72+
let recomputeCount = 0;
73+
74+
const delegated = delegatedSignal({
75+
source: () => ({ query: query(), limit: limit() }),
76+
update: ({ query: q, limit: l }) => {
77+
query.set(q);
78+
limit.set(l);
79+
},
80+
equal: (a, b) => a.query === b.query,
81+
});
82+
83+
const downstream = computed(() => {
84+
recomputeCount++;
85+
return delegated();
86+
});
87+
88+
downstream();
89+
expect(recomputeCount).toBe(1);
90+
91+
limit.set(25);
92+
downstream();
93+
expect(recomputeCount).toBe(1);
94+
95+
query.set('ngrx');
96+
downstream();
97+
expect(recomputeCount).toBe(2);
98+
});
99+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {
2+
computed,
3+
untracked,
4+
ValueEqualityFn,
5+
WritableSignal,
6+
} from '@angular/core';
7+
8+
export type DelegatedSignalConfig<T> = {
9+
source: () => T;
10+
update: (value: NoInfer<T>) => void;
11+
equal?: ValueEqualityFn<NoInfer<T>>;
12+
};
13+
14+
/**
15+
* @description
16+
*
17+
* Creates a `WritableSignal` whose reads are delegated to a source computation
18+
* and whose writes are delegated to an `update` callback.
19+
*
20+
* @usageNotes
21+
*
22+
* ```ts
23+
* import { Component, inject } from '@angular/core';
24+
* import {
25+
* delegatedSignal,
26+
* patchState,
27+
* signalStore,
28+
* withMethods,
29+
* withState,
30+
* } from '@ngrx/signals';
31+
*
32+
* const CounterStore = signalStore(
33+
* withState({ count: 0 }),
34+
* withMethods((store) => ({
35+
* updateCount(count: number): void {
36+
* patchState(store, { count });
37+
* },
38+
* })),
39+
* );
40+
*
41+
* \@Component({
42+
* selector: 'app-counter',
43+
* template: '...',
44+
* providers: [CounterStore],
45+
* })
46+
* class Counter {
47+
* readonly #store = inject(CounterStore);
48+
*
49+
* readonly count = delegatedSignal({
50+
* source: this.#store.count,
51+
* update: this.#store.updateCount,
52+
* });
53+
* }
54+
* ```
55+
*/
56+
export function delegatedSignal<T>(
57+
config: DelegatedSignalConfig<T>
58+
): WritableSignal<T> {
59+
const source = computed(config.source, { equal: config.equal });
60+
const delegated = computed(source) as WritableSignal<T>;
61+
62+
delegated.set = (value) => config.update(value);
63+
delegated.update = (updater) => config.update(updater(untracked(source)));
64+
delegated.asReadonly = () => source;
65+
66+
return delegated;
67+
}

modules/signals/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { deepComputed } from './deep-computed';
22
export { DeepSignal } from './deep-signal';
3+
export { delegatedSignal, DelegatedSignalConfig } from './delegated-signal';
34
export { signalMethod, SignalMethod } from './signal-method';
45
export { signalState, SignalState } from './signal-state';
56
export { signalStore } from './signal-store';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# DelegatedSignal
2+
3+
The `delegatedSignal` function creates a `WritableSignal` whose reads are delegated to a `source` computation and whose writes are delegated to an `update` callback.
4+
It is useful for bridging two state containers, such as synchronizing SignalStore with Signal Forms as shown in the example below.
5+
6+
<ngrx-code-example>
7+
8+
```ts
9+
import { Component, inject } from '@angular/core';
10+
import {
11+
form,
12+
FormField,
13+
minLength,
14+
required,
15+
} from '@angular/forms/signals';
16+
import {
17+
delegatedSignal,
18+
patchState,
19+
signalStore,
20+
withMethods,
21+
withState,
22+
} from '@ngrx/signals';
23+
24+
type User = { id: number; name: string };
25+
26+
const UsersStore = signalStore(
27+
{ providedIn: 'root' },
28+
withState({ query: '', limit: 10, users: [] as User[] }),
29+
withMethods((store) => ({
30+
updateFilter(query: string, limit: number): void {
31+
patchState(store, { query, limit });
32+
},
33+
}))
34+
);
35+
36+
@Component({
37+
selector: 'app-user-list',
38+
imports: [FormField],
39+
template: `
40+
<input type="text" [formField]="filterForm.query" />
41+
<input type="number" [formField]="filterForm.limit" />
42+
43+
<ul>
44+
@for (user of users(); track user.id) {
45+
<li>{{ user.name }}</li>
46+
}
47+
</ul>
48+
`,
49+
})
50+
class UserList {
51+
readonly #store = inject(UsersStore);
52+
53+
readonly users = this.#store.users;
54+
readonly filter = delegatedSignal({
55+
source: () => ({
56+
query: this.#store.query(),
57+
limit: this.#store.limit(),
58+
}),
59+
update: ({ query, limit }) => {
60+
this.#store.updateFilter(query, limit);
61+
},
62+
});
63+
64+
readonly filterForm = form(this.filter, (schemaPath) => {
65+
required(schemaPath.query);
66+
minLength(schemaPath.query, 2);
67+
});
68+
}
69+
```
70+
71+
</ngrx-code-example>

projects/www/src/app/services/guide-menu.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export class GuideMenuService {
9898
]),
9999
link('SignalState', '/guide/signals/signal-state'),
100100
link('DeepComputed', '/guide/signals/deep-computed'),
101+
link('DelegatedSignal', '/guide/signals/delegated-signal'),
101102
link('SignalMethod', '/guide/signals/signal-method'),
102103
link('RxJS Integration', '/guide/signals/rxjs-integration'),
103104
link('FAQ', '/guide/signals/faq'),

0 commit comments

Comments
 (0)