Skip to content

Commit

Permalink
feat: add explicitEffect helper function. (#393)
Browse files Browse the repository at this point in the history
* feat: add explicitEffect helper function.

Build on top of `effect()`, this helper function allows to only watch the producers passed as dependencies.

The effect itself is run as untracked preventing any hidden side effects.

* feat: update types and API for explicitEffect

* docs: added docs for explicit-effect

* refactor: add unit tests to `explicitEffect()`

---------

Co-authored-by: Enea Jahollari <[email protected]>
  • Loading branch information
JeanMeche and eneajaho authored May 29, 2024
1 parent 14ce49b commit 9bef713
Show file tree
Hide file tree
Showing 8 changed files with 277 additions and 0 deletions.
7 changes: 7 additions & 0 deletions docs/src/content/contributors/jeanmeche.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "Matthieu Riegler",
"twitter": "https://twitter.com/jean__meche",
"linkedin": "https://www.linkedin.com/in/matthieuriegler/",
"github": "https://github.com/jeanmeche",
"website": "https://riegler.fr"
}
81 changes: 81 additions & 0 deletions docs/src/content/docs/utilities/Signals/explicit-effect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
title: explicitEffect
description: ngxtension/explicit-effect
entryPoint: explicit-effect
badge: stable
contributors: ['jeanmeche', 'enea-jahollari']
---

`explicitEffect` is a helper function that allows you to create an effect that only depends on the provided signals in the deps array.
It will run the effect function only when the signals in the deps array change.

Think about this pattern:

```ts
import { untracked } from '@angular/core';

effect(() => {
// read the signals here
const count = this.count();
const state = this.state();

// run the side effect as untracked to prevent any unnecessary signal reads or writes
untracked(() => {
log.push(`count updated ${count}, ${state}`);
});
});
```

This pattern is very common in Angular apps, and it's used to prevent unnecessary signal reads or writes when the effect function is called.

`explicitEffect` is a helper function that does exactly this, but in a more explicit way.

```ts
explicitEffect([this.count, this.state], ([count, state]) => {
log.push(`count updated ${count}, ${state}`);
});
```

```ts
import { explicitEffect } from 'ngxtension/explicit-effect';
```

## Usage

`explicitEffect` accepts an array of signals and a function that will be called when the signals change.
The deps array accepts:

- Signals (also computed signals)
- Writable signals
- Functions that have signal reads as dependencies (ex: `() => this.count()`)

```ts
const count = signal(0);
const state = signal('idle');
const doubleCount = computed(() => count() * 2);
const result = () => count() + doubleCount();

explicitEffect(
[count, state, doubleCount, result],
([count, state, doubleCount, result]) => {
console.log(`count updated ${count}, ${state}, ${doubleCount}, ${result}`);
},
);
```

## Cleanup

An optional second argument can be provided to `explicitEffect` that will be called when the effect is cleaned up.

```ts
const count = signal(0);
explicitEffect([this.count], ([count], cleanup) => {
console.log(`count updated ${count}`);
cleanup(() => console.log('cleanup'));
});

// count updated 0
// count.set(1);
// cleanup
// count updated 1
```
3 changes: 3 additions & 0 deletions libs/ngxtension/explicit-effect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/explicit-effect

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/explicit-effect`.
5 changes: 5 additions & 0 deletions libs/ngxtension/explicit-effect/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
20 changes: 20 additions & 0 deletions libs/ngxtension/explicit-effect/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "ngxtension/explicit-effect",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/ngxtension/explicit-effect/src",
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ngxtension/jest.config.ts",
"testPathPattern": ["explicit-effect"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
}
}
}
109 changes: 109 additions & 0 deletions libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Component, computed, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { explicitEffect } from './explicit-effect';

describe(explicitEffect.name, () => {
let log: string[] = [];
let cleanupLog: string[] = [];

beforeEach(() => {
log = [];
cleanupLog = [];
});

@Component({
standalone: true,
template: '',
})
class Foo {
count = signal(0);
state = signal('idle');
foobar = signal<'foo' | 'bar'>('foo');

eff = explicitEffect(
[this.count, this.state],
([count, state], cleanUpFn) => {
this.foobar();
log.push(`count updated ${count}, ${state}`);

cleanUpFn(() => {
cleanupLog.push('cleanup');
});
},
);
}

it('should register deps and run effect', () => {
const fixture = TestBed.createComponent(Foo);
fixture.detectChanges();
expect(log.length).toBe(1);

fixture.componentInstance.count.set(1);
fixture.detectChanges();
expect(log.length).toBe(2);
});

it('should not run when unresgistered dep', () => {
const fixture = TestBed.createComponent(Foo);
fixture.detectChanges();
expect(log.length).toBe(1);

fixture.componentInstance.foobar.set('foo');
fixture.detectChanges();
expect(log.length).toBe(1);
});

it('should run the effect cleanupFn', () => {
const fixture = TestBed.createComponent(Foo);
expect(log.length).toBe(0);
fixture.detectChanges();
expect(log.length).toBe(1);
expect(cleanupLog.length).toBe(0);

fixture.componentInstance.count.set(1);
TestBed.flushEffects();
expect(log.length).toBe(2);
expect(cleanupLog.length).toBe(1);
});

it('should accept computed, functions etc.', () => {
const log: string[] = [];
const count = signal(0);
const state = signal('idle');
const doubleCount = computed(() => count() * 2);
const foobar = signal<'foo' | 'bar'>('foo');
const result = () => count() + doubleCount() + foobar();

TestBed.runInInjectionContext(() => {
explicitEffect(
[count, state, doubleCount, result],
([count, state, doubleCount, result]) => {
log.push(
`count updated ${count}, ${state}, ${doubleCount}, ${result}`,
);
},
);
expect(log.length).toBe(0);
TestBed.flushEffects();
expect(log.length).toBe(1);

foobar.set('bar');
TestBed.flushEffects();
expect(log.length).toBe(2);
expect(log.some((v) => v.includes('bar'))).toBeTruthy();
});
});

it('should pass the right types to the effect callback', () => {
const count = signal(0);
const state = signal('idle');

TestBed.runInInjectionContext(() => {
explicitEffect([count, state], ([count, state]) => {
const _count: number = count;
const _state: string = state;
console.log(_count, _state);
});
});
});
});
51 changes: 51 additions & 0 deletions libs/ngxtension/explicit-effect/src/explicit-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
CreateEffectOptions,
EffectCleanupRegisterFn,
EffectRef,
effect,
untracked,
} from '@angular/core';

/**
* We want to have the Tuple in order to use the types in the function signature
*/
type ExplicitEffectValues<T> = {
[K in keyof T]: () => T[K];
};

/**
* This explicit effect function will take the dependencies and the function to run when the dependencies change.
*
* @example
* ```typescript
* import { explicitEffect } from 'ngxtension/explicit-effect';
*
* const count = signal(0);
* const state = signal('idle');
*
* explicitEffect([count, state], ([count, state], cleanup) => {
* console.log('count updated', count, state);
*
* cleanup(() => {
* console.log('cleanup');
* });
* });
* ```
*
* @param deps - The dependencies that the effect will run on
* @param fn - The function to run when the dependencies change
* @param options - The options for the effect
*/
export function explicitEffect<
Input extends readonly unknown[],
Params = Input,
>(
deps: readonly [...ExplicitEffectValues<Input>],
fn: (deps: Params, onCleanup: EffectCleanupRegisterFn) => void,
options?: CreateEffectOptions | undefined,
): EffectRef {
return effect((onCleanup) => {
const depValues = deps.map((s) => s());
untracked(() => fn(depValues as any, onCleanup));
}, options);
}
1 change: 1 addition & 0 deletions libs/ngxtension/explicit-effect/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './explicit-effect';

0 comments on commit 9bef713

Please sign in to comment.