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
96 changes: 77 additions & 19 deletions docs/src/content/docs/utilities/Signals/create-notifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,36 @@ title: createNotifier
description: ngxtension/create-notifier
entryPoint: ngxtension/create-notifier
badge: experimental
contributors: ['josh-morony']
contributors: ['josh-morony', 'endlacer']
---

`createNotifier` provides a way to manually trigger signal re-computations by
referencing the created notifier signal. Common use cases for this include:
## Features

`createNotifier` provides a way to manually trigger signal re-computations by referencing the created notifier signal. It can also automatically react to changes in other signals through dependency tracking.

### Common Use Cases

- Manually triggering a "refresh" and reacting to it
- Mutating a `Map` and having a way to react to that map changing
- Triggering a re-computation on some kind of event/life cycle hook
- Combining manual notifications with automatic dependency tracking

It simply creates a standard `signal` that has its value incremented by `1`
every time `notify` is called. This means the signal value will change and any
`effect` or `computed` that references this signal will be re-computed.
## Basic Usage

You can create a notifier like this:
Create a simple notifier:

```ts
import { createNotifier } from 'ngxtension/create-notifier';
```

```ts
refreshNotifier = createNotifier();
```

You can trigger the signal update like this:
Trigger the signal update:

```ts
refreshNotifier.notify();
```

Then you can trigger a re-computation of any `computed` or `effect` (or
`derivedAsync` from `ngxtension`) by referencing the signal returned on
`listen`:
React to notifications in `computed` or `effect`:

```ts
effect(() => {
Expand All @@ -46,10 +43,9 @@ effect(() => {
});
```

An important thing to keep in mind is that an `effect` will also run once
initially before `notify()` is explicitly called. Since the version number used
internally for the signals value begins with `0` you can avoid this "init"
behaviour by setting up your effect like this instead:
### Avoiding Initial Effect Execution

An `effect` runs once initially before `notify()` is explicitly called. Since the internal counter begins at `0`, you can skip the initial run:

```ts
effect(() => {
Expand All @@ -61,4 +57,66 @@ effect(() => {
});
```

With this set up, the `if` will initially fail because the value of `refreshNotifier.listen()` will initially be `0`, but once `notify` has been explicitly called the `if` condition will always pass because the value of the signal will always be above `0`.
The `if` condition initially fails because `refreshNotifier.listen()` returns `0`, but passes once `notify()` has been called.

## Dependency Tracking

You can configure a notifier to automatically increment whenever specified signals change. This combines manual notifications with reactive dependency tracking.

### Basic Dependency Tracking

```ts
userId = signal(1);

userNotifier = createNotifier({
deps: [this.userId],
});
```

Now `userNotifier.listen()` will increment both when `notify()` is called **and** when `userId` changes:

```ts
effect(() => {
console.log('User notifier changed:', userNotifier.listen());
// Runs when userId changes OR when notify() is called
});

// Both of these will trigger the effect:
userId.set(2); // Triggers via dependency
userNotifier.notify(); // Triggers manually
```

### Multiple Dependencies

Track multiple signals simultaneously:

```ts
userId = signal(1);
tenantId = signal('tenant-a');

compositeNotifier = createNotifier({
deps: [userId, tenantId],
});
```

The notifier increments whenever **any** of the dependencies change.

### Controlling Initial Emission

By default, notifiers with dependencies start at `1` (emitting immediately). Control this with `depsEmitInitially`:

```ts
// Emit immediately (default behavior)
notifier = createNotifier({
deps: [someSignal],
depsEmitInitially: true, // starts at 1 (default)
});

// Don't emit initially
notifier = createNotifier({
deps: [someSignal],
depsEmitInitially: false, // starts at 0
});
```

When `depsEmitInitially: false`, the notifier starts at `0` like a dependency-free notifier, even though it tracks signals. The first increment happens only when dependencies change or `notify()` is called.
93 changes: 92 additions & 1 deletion libs/ngxtension/create-notifier/src/create-notifier.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { effect } from '@angular/core';
import { effect, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { createNotifier } from './create-notifier';

Expand Down Expand Up @@ -63,4 +63,95 @@ describe(createNotifier.name, () => {
expect(testFn).toHaveBeenCalledTimes(2);
});
});

it('should work with options.deps', () => {
TestBed.runInInjectionContext(() => {
let notifyValue: number;
const dep1 = signal<any>(null);
const dep2 = signal<any>(null);
const testFn = jest.fn();
const trigger = createNotifier({ deps: [dep1, dep2] });

effect(() => {
notifyValue = trigger.listen();
testFn();
});

TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(1);
expect(notifyValue!).toBe(1);

trigger.notify();
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(2);
expect(notifyValue!).toBe(2);

dep1.set(1);
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(3);
expect(notifyValue!).toBe(3);

dep1.set(2);
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(4);
expect(notifyValue!).toBe(4);

dep1.set(2);
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(4);
expect(notifyValue!).toBe(4);

trigger.notify();
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(5);
expect(notifyValue!).toBe(5);
});
});

it('should work with options.deps and options.depsEmitInitially=false', () => {
TestBed.runInInjectionContext(() => {
let notifyValue: number;
const dep1 = signal<any>(null);
const dep2 = signal<any>(null);
const testFn = jest.fn();
const trigger = createNotifier({
deps: [dep1, dep2],
depsEmitInitially: false,
});

effect(() => {
notifyValue = trigger.listen();
testFn();
});

TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(1);
expect(notifyValue!).toBe(0);

trigger.notify();
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(2);
expect(notifyValue!).toBe(1);

dep1.set(1);
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(3);
expect(notifyValue!).toBe(2);

dep1.set(2);
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(4);
expect(notifyValue!).toBe(3);

dep1.set(2);
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(4);
expect(notifyValue!).toBe(3);

trigger.notify();
TestBed.flushEffects();
expect(testFn).toHaveBeenCalledTimes(5);
expect(notifyValue!).toBe(4);
});
});
});
44 changes: 38 additions & 6 deletions libs/ngxtension/create-notifier/src/create-notifier.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,49 @@
import { signal } from '@angular/core';
import { linkedSignal, signal, Signal } from '@angular/core';

type CreateNotifierOptions = Record<string, any> & {
deps: Signal<any>[];
depsEmitInitially?: boolean;
};

const DEFAULT_OPTIONS: Required<CreateNotifierOptions> = {
deps: [],
depsEmitInitially: true,
};

/**
* Creates a signal notifier that can be used to notify effects or other consumers.
*
* @returns A notifier object.
*/
export function createNotifier() {
const sourceSignal = signal(0);
export function createNotifier(options?: CreateNotifierOptions) {
options = {
...DEFAULT_OPTIONS,
...options,
};

// without explicit deps we can simplify to a simple signal
const sourceSignal = !options.deps.length
? signal(0)
: linkedSignal<number, number>({
source: () => {
options.deps?.forEach((dep) => dep()); // Track all dependencies

// - when deps exist, the notifier should start at 1, because it immediately emits.
// -without any deps, it is only based on increments. and those should start at 0.
return options.deps?.length && options.depsEmitInitially ? 1 : 0;
},
// Return a new value each time source runs. This ensures deps changes also increment the counter
computation: (currentIncrementer, previousValue) => {
// Increment from previous value when deps change
return previousValue !== undefined
? previousValue.value + 1
: currentIncrementer;
},
equal: () => false, // Always notify downstream consumers
});

return {
notify: () => {
sourceSignal.update((v) => (v >>> 0) + 1);
},
notify: () => sourceSignal.update((v) => (v >>> 0) + 1),
listen: sourceSignal.asReadonly(),
};
}
Loading