Transform Angular signals with functional operators.
npm install ngx-signal-operators- Angular >= 17.0.0
- TypeScript >= 5.2.0
- Purely written using Signals.
- Depends only on
@angular/core. - No dependency on
RxJS. - < 2KB minified.
ngx-signal-operators was created to provide clean, simple, and readable solutions to common and repeatable signals patterns.
This is common, as the first callback is usually with the initial signal values before the user interacts with the component.
Without ngx-signal-operators:
const input = signal(''); let isFirst = true; effect(() => { const value = input(); // Establish dependency. if (isFirst) { isFirst = false; return; } console.log(`Input changed to: ${value}`); });With ngx-signal-operators:
import { effectWith } from 'ngx-signal-operators'; const input = signal(''); effectWith(input) .skip(1) .run(value => console.log(`Input changed to: ${value}`));This is common when working with APIs where user input needs to be debounced to avoid spamming the API:
Without ngx-signal-operators:
const input = signal(''); const input$ = toObservable(input).pipe( debounceTime(500) ); const debouncedInput = toSignal(input$, { initialValue: input() });With ngx-signal-operators:
import { computedWith } from 'ngx-signal-operators'; const input = signal(''); const debouncedInput = computedWith(input).debounce(500);While both libraries have a similar pipe-able operators API, they operate on fundamentally different primitives:
- Signals are pull-based and always have a value.
- Observables are push-based streams.
ngx-signal-operators focuses on providing simple solutions to common signals patterns. While some functionality may overlap, the implementation is specifically optimised for Signals.
Use ngx-signal-operators when working primarily with Signals and simple transformations.
Use RxJS when dealing with complex async flows, event streams, error handling or when you need its rich operator ecosystem.
ngx-signal-operators provides two main functions built purely with Angular Signals:
effectWith: For handling side effects based on signal changes.computedWith: For transforming signal values.
Signals reactivity works in two phases:
-
Notifying Dependents Phase
When a signal's value changes, it immediately marks all its dependents (such as computed signals, effects or component template) as "dirty".
This notification means that any cached values are flagged for update, ensuring that dependent computations know they must refresh their value, but they are not re-evaluated immediately. -
Lazy Evaluation Phase
The value of dependents is lazily recalculated only when their value is actually accessed. This on-demand "pull" minimises unnecessary recomputation, enhancing performance by recalculating only when needed.
This is crucial for understanding how the various operators in this library work.
effectWith creates an effect pipeline with one or more input signals.
import { signal } from '@angular/core'; import { effectWith } from 'ngx-signal-operators'; const temperature = signal(20); effectWith(temperature) .skip(1) // Skip initial value .filter(t => t > 30) // Only high temps .debounce(1000) // Avoid spam .run(t => { console.log(`Warning: High temperature ${t}°C`); });💡 Performance Tip: For optimal performance, add
{ untracked: true }to therun()options unless you need to track additional signals inside your effect. See Untracked Effects for details.
effectWith is implemented using a single effect and a pipeline pattern, it roughly translates to this:
effect(() => operator1(operator2(operator3(run))));Each operator decides whether to call the next operator and so on, until the run callback is invoked.
effects called from an Angular component run during the change detection stage of the component's lifecycle.
effects called from outside an Angular component run as a microtask.
effectWith operates on each one of those effect runs.
Conditionally run effect based on predicate.
const input = signal(0); effectWith(input) .filter(x => x % 2 === 0) .run(value => { console.log('Even number:', value); });Map values using a mapping function.
const input = signal(1); effectWith(input) .map(x => x * 2) .run(value => console.log('Doubled value:', value));Skip the first N effect runs.
const input = signal(0); effectWith(input) .skip(1) .run(value => { console.log('After initial value:', value); });Skip consecutive duplicate values based on a custom equality function.
equal comparator is mandatory because signal values are inherently distinct e.g. distinct((a, b) => a === b) is redundant.
// Distinct by property const source = signal({ id: 1, name: 'Alice' }); effectWith(source) .distinct((a, b) => a.id === b.id) .run(value => { console.log('New unique object:', value); }); // Deep equality const coords = signal({ x: 1, y: 2 }); effectWith(coords) .distinct(deepEqual) .run(value => { console.log('Coordinates changed:', value); });Run effect N times before destroying it.
const input = signal(0); effectWith(input) .take(1) .run(value => { console.log('Initial value:', value); });Pair each value with its previous value.
The first value will be paired with undefined (since there is no previous value).
const counter = signal(0); effectWith(counter) .pair() .run(([current, previous]) => { console.log(`Counter changed from ${previous ?? 'undefined'} to ${current}`); });Delay effect run by the specified milliseconds.
const input = signal(''); effectWith(input) .debounce(500) .run(value => { console.log('Debounced input:', value); });Executes the effect with the configured pipeline.
const input = signal(''); const effectRef = effectWith(input) .filter(value => value.length > 0) .run( (value, { onCleanup, effectRef }) => { // Register cleanup logic if needed onCleanup(() => { console.log('Cleaning up after:', value); }); }, { injector } );Parameters:
fn: Callback function that receives:value: The current value(s) from the source signal(s)context: An object with:onCleanup: Function to register cleanup handlerseffectRef: Reference to the effect instance
options:- extends
CreateEffectOptions untracked?: boolean- Wrap the effect pipeline with `untracked(() => ...).
- extends
Returns an EffectRef that can be used to destroy the effect manually.
By default, Angular's effect tracks all signal reads within its callback. This means any signal accessed inside your effect will create a dependency, causing the effect to re-run whenever those signals change, even if you only want to react to changes in your input signal(s).
If you only want to react to changes in effectWith input signal(s) then set untracked: true option.
// effect ONLY runs when input changes effectWith(input) .run( value => console.log(value, other()), // Changes to `other` signal will not re-run the effect. { untracked: true } );This automatically wraps the effect pipeline in Angular's untracked(), preventing unwanted reactive dependencies and improving performance.
When untracked: true (default) combining synchronous and asynchronous operators (like debounce), the library automatically splits the pipeline into two effects connected by a bridge signal. This maintains reactivity across async contexts:
effectWith(input) .map(x => x * 2) // Synchronous - runs in effect 1 .debounce(500) // Async boundary - automatic split .filter(x => x > 10) // Synchronous - runs in effect 2 .run(value => console.log(value, other())); // Changes to `other` signal re-runs the effect.The split happens transparently, maintaining reactivity across async contexts.
computedWith creates a computed signal pipeline with one or more input signals.
import { signal } from '@angular/core'; import { computedWith } from 'ngx-signal-operators'; const input = signal(''); const normalisedInput = computedWith(input) .skip(1) // Skip initial empty value .filter(v => v.length > 0) // Ignore empty strings .debounce(500) // Wait for typing to stop .map(v => v.toLowerCase()); // Normalise case input.set('HELLO'); // After 500ms: "hello"computedWith is implemented by simply chaining multiple computed signals. It roughly translates to:
const input = signal(0); const intermediate1 = computed(operator1(input)); const intermediate2 = computed(operator2(intermediate1)); const output = computed(operator3(intermediate2));Each operator applies a transformation to the value which in turn is pulled by the next computed value and so on...
computed value computation is performed when the value is accessed. Then the value is cached until the next time one of its dependencies changes.
The timing of the computation depends on when the signal is accessed:
- If the
computedvalue is accessed from a component's template, then it will run as part of the component's rendering cycle. - If the
computedvalue is accessed from aneffect, then it follows theeffecttiming explained previously.
computedWith operates on each one of those value computations.
Map values using a mapping function.
const input = signal(1); const doubled = computedWith(input).map(x => x * 2);Filter values based on a predicate.
If the initial value is filtered then SKIPPED is returned.
const input = signal(0); const evenOnly = computedWith(input).filter(x => x % 2 === 0);Returns SKIPPED for the first N computations, then passes through subsequent values as-is.
const input = signal(0); const skipFirst = computedWith(input).skip(1);Skip consecutive duplicate values based on a custom equality function.
equal comparator is mandatory because signal values are inherently distinct e.g. distinct((a, b) => a === b) is redundant.
const source = signal({ id: 1, name: 'Alice' }); const uniqueById = computedWith(source) .distinct((a, b) => a.id === b.id); source.set({ id: 1, name: 'Bob' }); uniqueById(); // { id: 1, name: 'Alice' } source.set({ id: 2, name: 'Charlie' }); uniqueById(); // { id: 2, name: 'Charlie' } // Deep equality const coords = signal({ x: 1, y: 2 }); const uniqueCoords = computedWith(coords) .distinct(deepEqual);Returns value as-is for the first N computations, then retains the N-th value for all subsequent computations.
It also calls destroy() on the ComputedWithSignal instance to cleanup any internal effects.
const input = signal(0); const takeFirst = computedWith(input).take(1);Pair each value with its previous value.
The first value will be paired with undefined (since there is no previous value).
const counter = signal(0); const counterWithPrevious = computedWith(counter).pair(); // Initial value: [0, undefined] counter.set(1); // Now: [1, 0] counter.set(2); // Now: [2, 1]Delay value computation by the specified milliseconds.
const input = signal(''); const debouncedInput = computedWith(input).debounce(500); input.set('a'); // Will emit after 500ms input.set('ab'); // Resets the 500ms timerSignals always have a value, using debounce with computedWith returns the initial signal value instantly, then debounces future value changes.
debounce uses an internal effect which is automatically cleaned up when the component is destroyed, but it can be done manually by calling computedWithSignal.destroy().
Replace SKIPPED with the specified default value.
const input = signal(''); const skipWithDefault = computedWith(input) .skip(1) // This will produce SKIPPED initially .default('None'); // Replace SKIPPED with 'None'Since signals always have a value, if a computation results in a skipped value, then a special SKIPPED symbol is returned instead.
SKIPPED only applies to the initial value produced by computedWith. All subsequent skipped computations simply return the last known value, which in turn does not notify dependents of a value change, essentially, skipping that computation.
In this example, we'd like to debounce search parameters that are entered by the user before making an API call:
@Component({ /* ... */ }) export class EmployeesComponent { private readonly employeeApiService = inject(EmployeeApiService); public readonly firstName = input<string>(''); public readonly lastName = input<string>(''); // ⚠️ Will trigger an immediate API call with empty values protected readonly employeesResource = rxResource({ request: computedWith(this.firstName, this.lastName).debounce(500), loader: params => { const [firstName, lastName] = params.request; return this.employeeApiService.searchEmployees({ firstName, lastName }); } }); }In this example, because signals always have a value, the resource will immediately make an API call with empty strings.
If we want to make a request only after the user has entered search parameters, you can use skip(1) and handle the SKIPPED symbol:
@Component({ /* ... */ }) export class EmployeesComponent { private readonly employeeApiService = inject(EmployeeApiService); public readonly firstName = input<string>(''); public readonly lastName = input<string>(''); // ✅ No API call until user interaction protected readonly employeesResource = rxResource({ request: computedWith(this.firstName, this.lastName) .skip(1) // <= Skip initial value .debounce(500), loader: params => { if (params.request === SKIPPED) { return EMPTY; // Prevent initial HTTP request } const [firstName, lastName] = params.request; return this.employeeApiService.searchEmployees({ firstName, lastName }); } }); }Or you can use the default operator:
@Component({ /* ... */ }) export class EmployeesComponent { private readonly employeeApiService = inject(EmployeeApiService); public readonly firstName = input<string>(''); public readonly lastName = input<string>(''); // ✅ No API call until user interaction protected readonly employeesResource = rxResource({ request: computedWith(this.firstName, this.lastName) .skip(1) // <= Skip initial value .debounce(500) .default(), // <= Replace SKIPPED with `undefined` loader: params => { // rxResource does not invoke the loader function when request is `undefined` const [firstName, lastName] = params.request; return this.employeeApiService.searchEmployees({ firstName, lastName }); } }); }Similarly, computedWith works with httpResource:
protected readonly employeesResource = httpResource<Array<Employee>>( computedWith(this.firstName, this.lastName) .debounce(500) .map(([firstName, lastName]): HttpResourceRequest => ({ url: `https://api.dev/employees`, params: { firstName, lastName } })) );Both computedWith and effectWith support multiple input signals:
const name = signal('John'); const age = signal(30); // computedWith computedWith(name, age) .map(([n, a]) => `${n} is ${a} years old`); computedWith(() => [name(), age()]) .map(([n, a]) => `${n} is ${a} years old`); // effectWith effectWith(name, age).run(([n, a]) => { console.log(`Update: ${n} is now ${a}`); }); effectWith(() => [name(), age()]).run(([n, a]) => { console.log(`Update: ${n} is now ${a}`); });Both effectWith and computedWith require an injection context. When used outside of constructor or field initialisation, you must provide an Injector:
@Component({ /* ... */ }) export class MyComponent implements OnInit { private readonly injector = inject(Injector); private readonly source = signal(0); // ✅ No injector needed - field initialisation has context private readonly doubled = computedWith(this.source) .map(x => x * 2); public ngOnInit() { // ❌ ngOnInit has no context, must provide injector effectWith(this.source) .run( value => console.log(value), { injector: this.injector } ); } }Contributions are welcome! You can start by forking the repository.
If you encounter any bugs, have a feature request, or a use case for a new operator, please open an issue.
This project is licensed under the MIT License - see the LICENSE file for details.