-
Notifications
You must be signed in to change notification settings - Fork 88
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add explicitEffect helper function. (#393)
* 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
Showing
8 changed files
with
277 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
81
docs/src/content/docs/utilities/Signals/explicit-effect.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"lib": { | ||
"entryFile": "src/index.ts" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
109
libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './explicit-effect'; |