npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ngx-deep-signals

v0.1.1

Published

Angular Signals without the noise — transparent reactive proxies, decorators, and a MobX-style class wrapper, all backed by native Angular signals.

Readme

ngx-deep-signals

Code like NgZone. React like Signals.
One line turns any class, object, or array into a deeply reactive signal graph — no (), no .set(), no .update(), no .asReadonly().

Angular Signals feel great for a single boolean, number, or derived value. They start feeling painful when state becomes nested: a profile inside a user, items inside a cart, filters inside a dashboard. At that point, the explicit API stops documenting intent and starts leaking implementation details into every line of business code.

explicit > implicit is not a law. It is only true when the explicit form makes code easier to read, change, and trust. For nested state, repetitive signal(), computed(), .set(), .update(), readonly mirrors, and () calls often do the opposite: they make simple domain logic look mechanically complex. ngx-deep-signals keeps Angular's signal graph, but removes the ceremony that makes deeply reactive state feel heavier than the problem you're solving.

npm license


The problem with vanilla Signals

Angular Signals are fast and zoneless — but they trade ergonomics for explicitness. Once your state is anything beyond a primitive, the API gets in your way:

// NgZone — what your brain writes:
this.user.profile.name = 'Anna';
this.cart.items.push(item);
this.discount = 10;
const name = this.user.profile.name;

// Vanilla Signals — what you have to write:
this._user.update(u => ({ ...u, profile: { ...u.profile, name: 'Anna' } }));
this._items.update(arr => [...arr, item]);
this._discount.set(10);
const name = this._user().profile.name;  

A signal() is a single atomic cell. The moment your state has nested objects, arrays, or a class instance, you face two bad options:

| Option | Cost | |---|---| | Wrap everything in one signal({ ...bigObject }) | Lose granularity — every read of state().a.b.c depends on everything | | Explode each leaf into its own signal | Boilerplate explosion — 5 fields → 10 declarations + readonly aliases + computed |

There is no "just make this object reactive" primitive in vanilla Signals.
That is exactly what ngx-deep-signals provides.


The solution — write NgZone, get Signals

import { deepSignalClass } from 'ngx-deep-signals';

@Injectable({ providedIn: 'root' })
export class CartService {
  items: CartItem[] = [];
  discount = 0;

  get total() {
    return this.items.reduce((s, i) => s + i.price * i.qty, 0)
      * (1 - this.discount / 100);
  }

  constructor() {
    deepSignalClass(this); // ← one line. That's the whole library.
  }

  addItem(item: CartItem) { this.items.push(item); }  // real reactive push
  setDiscount(pct: number) { this.discount = pct; }   // real reactive assign
  clear() { this.items = []; }
}

Every field becomes a signal(). Every getter becomes a computed().
items.push(...) triggers re-render. The code reads like NgZone, runs like Signals.

Side-by-side comparison

@Injectable({providedIn:'root'})
export class CartService {
  items: CartItem[] = [];
  discount = 0;

  get itemCount() {
    return this.items.length;
  }
  get total() {
    return this.items
      .reduce((s,i) => s+i.price, 0)
      * (1 - this.discount / 100);
  }

  addItem(i: CartItem) {
    this.items.push(i);
  }
}

Clean — but requires zone.js, no fine-grained reactivity.

@Injectable({providedIn:'root'})
export class CartService {
  items: CartItem[] = [];
  discount = 0;

  get itemCount() {
    return this.items.length;
  }
  get total() {
    return this.items
      .reduce((s,i) => s+i.price, 0)
      * (1 - this.discount / 100);
  }

  constructor() {
    deepSignalClass(this); // ← add this
  }

  addItem(i: CartItem) {
    this.items.push(i);
  }
}

Identical to NgZone + 1 line. Zoneless. Fine-grained reactivity.

@Injectable({providedIn:'root'})
export class CartService {
  private _items =
    signal<CartItem[]>([]);
  private _discount = signal(0);

  readonly items =
    this._items.asReadonly();
  readonly discount =
    this._discount.asReadonly();
  readonly itemCount = computed(
    () => this._items().length
  );
  readonly total = computed(() =>
    this._items()
      .reduce((s,i) => s+i.price, 0)
    * (1 - this._discount() / 100)
  );

  addItem(i: CartItem) {
    this._items.update(p => [...p,i]);
  }
}

2 fields → 4 signals + 2 readonly

  • 2 computed. Logic buried under ceremony.

Why this matters

  • Migration from NgZone → near zero cost. Add deepSignalClass(this) per service. Done.
  • Onboarding new devs → near zero friction. They write TypeScript. Reactivity is invisible.
  • Same performance as vanilla signals. The underlying primitives are WritableSignal and computedngx-deep-signals is a thin transparent layer, not a separate reactive runtime.
  • Per-property granularity for free. state.user.profile.name = 'Anna' only invalidates dependents that read name — not the whole user object.

Inspired by Vue's reactive() and MobX's makeAutoObservable, built on Angular's native signal graph.


Installation

npm install ngx-deep-signals

Peer dependency: @angular/core >= 17.

⚠️ Required tsconfig

This is the most important step. Without these flags, the property decorators (@DeepSignal, @DeepInput) silently no-op.

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false,   // ← critical
    "target": "ES2022"
  }
}

Why useDefineForClassFields: false?

With target >= ES2022, TypeScript defaults to useDefineForClassFields: true, which emits every class field as Object.defineProperty(this, '_field', ...). That creates an own property on the instance, shadowing the getter/setter installed by the decorator on the prototype — the decorator becomes dead code.

useDefineForClassFields: false restores the legacy behavior (this._field = value), which routes through the prototype setter where the decorator lives.

Per-field alternative: declare

If you can't change the global flag, mark individual fields with declare:

@DeepSignal(false) private declare _open: boolean;

declare tells TypeScript not to emit any JS for the field. Note: some bundlers (older swc/esbuild) may not respect declare reliably. useDefineForClassFields: false is the safer choice.


Quick start

1. deepSignal() — reactive POJO

import { effect } from '@angular/core';
import { deepSignal } from 'ngx-deep-signals';

const theme = deepSignal({
  mode: 'dark',
  colors: { primary: '#ff0000', bg: '#1a1a1a' },
  fontSize: 14,
});

effect(() => console.log(theme.colors.primary));

// Nested writes propagate automatically:
theme.colors.primary = '#00ff00';
theme.fontSize = 16;

2. @DeepSignal + @DeepComputed — service style

import { Injectable } from '@angular/core';
import { DeepSignal, DeepComputed } from 'ngx-deep-signals';

@Injectable({ providedIn: 'root' })
export class PopupService {
  @DeepSignal(false) private declare _open: boolean;

  @DeepComputed
  get open(): boolean { return this._open; }

  show() { this._open = true; }
  hide() { this._open = false; }
}

3. deepSignalClass() — MobX style

import { deepSignalClass } from 'ngx-deep-signals';

class CartStore {
  items: { name: string; price: number }[] = [];
  discount = 0;

  get total() {
    return this.items.reduce((s, i) => s + i.price, 0) * (1 - this.discount / 100);
  }

  add(name: string, price: number) {
    this.items.push({ name, price });
  }

  constructor() {
    deepSignalClass(this); // ← always last
  }
}

4. @DeepInput — reactive component inputs without ()

import { Component, Input } from '@angular/core';
import { DeepInput } from 'ngx-deep-signals';

@Component({ /* ... */ })
export class FooComponent {
  @DeepInput(false) @Input() isCompact!: boolean;
  // this.isCompact         → boolean (signal read)
  // this.isCompact = true  → signal.set(true)
}

The explicit @Input() is required — Angular AOT scans decorator metadata statically; it can't see runtime-only @DeepInput.


API

deepSignal<T>(value: T): T

Universal reactive wrapper. Dispatches by input type:

| Input | Result | |---|---| | Array | delegates to deepSignalArray (Proxy + version signal) | | Plain object ({} / Object.create(null)) | Proxy with per-property signals; nested objects/arrays wrapped recursively | | Primitive / class instance / function / null | returned as-is (no-op) |

This is the single entry point — equivalent to Vue's reactive(). For class instances use deepSignalClass instead; deepSignal deliberately won't touch them.

// POJO — per-property reactivity
const state = deepSignal({ count: 0, user: { name: 'Jan' } });
effect(() => console.log(state.user.name)); // registers dep on 'name' only
state.count = 1;              // triggers effects that read count
state.user.name = 'Anna';     // triggers effects that read user.name
state.user = { name: 'Ewa' }; // replacing nested object also reactive

// Array — same as calling deepSignalArray directly
const items = deepSignal(['a', 'b']);
effect(() => console.log(items.length));
items.push('c'); // triggers effect

deepSignalArray<T extends any[]>(arr: T): T

Wraps an array in a Proxy with a single version signal. Mutating methods and indexed assignment bump the version. Equivalent to deepSignal(arr) when arr is an array — exported separately for type-precision when you know the input.

const items = deepSignalArray<string[]>(['a', 'b']);

effect(() => console.log(items.length));

items.push('c');       // triggers effect → length: 3
items[0] = 'x';        // triggers effect
items.splice(1, 1);    // triggers effect
items.sort();          // triggers effect

deepSignalClass<T extends object>(instance: T): void

Walks own properties (plain values, arrays, nested POJOs) and prototype getters. Wraps fields in signals and getters in computed. Call last in your constructor.

@Injectable({ providedIn: 'root' })
class AuthStore {
  user: User | null = null;
  loading = false;

  get isLoggedIn() { return this.user !== null; } // → auto computed()

  constructor() {
    deepSignalClass(this); // ← always last
  }

  setUser(u: User) { this.user = u; }      // reactive assign
  logout()         { this.user = null; }   // reactive assign
}

const store = new AuthStore();
effect(() => console.log(store.isLoggedIn)); // tracks user
store.setUser({ name: 'Jan' });              // triggers effect → true

@DeepSignal<T>(initial: T)

Property decorator. Transparent reactive field. Requires useDefineForClassFields: false or declare.

class PopupService {
  @DeepSignal(false) private declare _open: boolean;
  @DeepSignal(0)     private declare _count: number;

  open()  { this._open = true; }   // signal.set(true)
  close() { this._open = false; }  // signal.set(false)
  inc()   { this._count++; }       // read + write, both reactive
}

const svc = new PopupService();
effect(() => console.log(svc._open)); // registers dependency
svc.open(); // triggers effect → true

@DeepComputed

Accessor decorator. Wraps a getter in computed(). Not affected by useDefineForClassFields.

class PopupService {
  @DeepSignal(false) private declare _open: boolean;
  @DeepSignal(0)     private declare _count: number;

  @DeepComputed
  get summary() {
    return `open=${this._open}, count=${this._count}`;
  }
  // summary is cached — recomputes only when _open or _count change

  open()  { this._open = true; }
  inc()   { this._count++; }
}

@DeepInput<T>(default: T)

Property decorator. Combine with an explicit @Input(). The field reads as a value, writes go through a signal.

@Component({
  selector: 'app-card',
  template: `<div [class.compact]="isCompact">...</div>`,
})
export class CardComponent {
  @DeepInput(false) @Input() isCompact!: boolean;
  // ↑ plain boolean read in template — no () needed
  // ↑ Angular sets it via normal @Input() setter → stored in signal

  @DeepComputed
  get padding() { return this.isCompact ? 4 : 16; } // reactive
}
// parent template:
// <app-card [isCompact]="someSignal()"></app-card>

unwrapSignal<T>(instance, key) / unwrapInput<T>(instance, key)

Returns the underlying WritableSignal<T> backing a @DeepSignal / @DeepInput field. Useful when you need to expose a Signal<T> to consumers or wire into toObservable() / effect() directly.

class PopupService {
  @DeepSignal(false) private declare _open: boolean;

  // Expose a read-only signal for consumers:
  readonly open$ = unwrapSignal<boolean>(this, '_open')!.asReadonly();

  // Or wire to RxJS:
  readonly open$$ = toObservable(unwrapSignal<boolean>(this, '_open')!);

  show() { this._open = true; }
}

// Consumer component:
// @Input() set open(v: boolean) { /* uses svc.open$ */ }
effect(() => console.log(svc.open$())); // reactive

// unwrapInput — same, but for @DeepInput fields:
const inputSig = unwrapInput<boolean>(this, 'isCompact');
effect(() => console.log(inputSig?.()));

Caveats

| Pitfall | Affected | Fix | |---|---|---| | useDefineForClassFields: true kills decorators | @DeepSignal, @DeepInput | Set to false or use declare | | Angular AOT can't see runtime inputs | @DeepInput | Add explicit @Input() | | Late-init fields aren't reactive | deepSignalClass | Initialise before calling, or use @DeepSignal |


License

MIT