diff --git a/docs/src/content/contributors/jeanmeche.json b/docs/src/content/contributors/jeanmeche.json new file mode 100644 index 00000000..fbd2e04d --- /dev/null +++ b/docs/src/content/contributors/jeanmeche.json @@ -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" +} diff --git a/docs/src/content/docs/utilities/Signals/explicit-effect.md b/docs/src/content/docs/utilities/Signals/explicit-effect.md new file mode 100644 index 00000000..5f77d995 --- /dev/null +++ b/docs/src/content/docs/utilities/Signals/explicit-effect.md @@ -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 +``` diff --git a/libs/ngxtension/explicit-effect/README.md b/libs/ngxtension/explicit-effect/README.md new file mode 100644 index 00000000..15052d5c --- /dev/null +++ b/libs/ngxtension/explicit-effect/README.md @@ -0,0 +1,3 @@ +# ngxtension/explicit-effect + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/explicit-effect`. diff --git a/libs/ngxtension/explicit-effect/ng-package.json b/libs/ngxtension/explicit-effect/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/explicit-effect/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/explicit-effect/project.json b/libs/ngxtension/explicit-effect/project.json new file mode 100644 index 00000000..1e4a7e97 --- /dev/null +++ b/libs/ngxtension/explicit-effect/project.json @@ -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}"] + } + } +} diff --git a/libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts b/libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts new file mode 100644 index 00000000..b7426f24 --- /dev/null +++ b/libs/ngxtension/explicit-effect/src/explicit-effect.spec.ts @@ -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); + }); + }); + }); +}); diff --git a/libs/ngxtension/explicit-effect/src/explicit-effect.ts b/libs/ngxtension/explicit-effect/src/explicit-effect.ts new file mode 100644 index 00000000..16e8ea8e --- /dev/null +++ b/libs/ngxtension/explicit-effect/src/explicit-effect.ts @@ -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 = { + [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], + 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); +} diff --git a/libs/ngxtension/explicit-effect/src/index.ts b/libs/ngxtension/explicit-effect/src/index.ts new file mode 100644 index 00000000..f22df15b --- /dev/null +++ b/libs/ngxtension/explicit-effect/src/index.ts @@ -0,0 +1 @@ +export * from './explicit-effect';