AI News Hub Logo

AI News Hub

I Built a Utility Library for Angular Signals, here's What I Learned

DEV Community
Felipe Leon

Angular Signals landed in Angular 16 and became stable in Angular 17. They're reactive, they're fast, and they make state management feel simple again. But after using them for a while, I kept writing the same boilerplate over and over. // Every. Single. Time. const filtered = computed(() => items().filter(x => x.active)); const mapped = computed(() => items().map(x => ({ ...x, label: x.name.toUpperCase() }))); const total = computed(() => items().reduce((sum, x) => sum + x.price, 0)); And don't even get me started on persisting a signal to localStorage: // Without a helper — just to persist one value const saved = localStorage.getItem('theme'); const theme = signal(saved ? JSON.parse(saved) : 'light'); constructor() { effect(() => { localStorage.setItem('theme', JSON.stringify(theme())); }); } That's 7 lines for something that should be one. So I built @signals-toolkit/core — a collection of utilities that covers the most common Angular Signals patterns. Here's the story. The library ships 14 helpers across 5 categories: Category Helpers Computed computedMap, computedFilter, computedReduce Storage signalStorage RxJS Bridge signalFromObservable, toObservable Rate Limiting debounceSignal, throttleSignal, distinctUntilChanged Advanced watchSignal, computedAsync, signalProfiler, signalGroup, persistedComputed Let me walk through the ones I use most. This one replaces the localStorage boilerplate completely. Before: const saved = localStorage.getItem('theme'); const theme = signal(saved ? JSON.parse(saved) : 'light'); constructor() { effect(() => { localStorage.setItem('theme', JSON.stringify(theme())); }); } After: import { signalStorage } from '@signals-toolkit/core'; const theme = signalStorage('theme', 'light'); Same behavior. One line. It restores the saved value on init, syncs on every .set() and .update(), and works exactly like a normal WritableSignal. theme.set('dark'); // saved to localStorage immediately theme.update(t => t === 'dark' ? 'light' : 'dark'); // works too theme.asReadonly(); // also works These three replace the most common computed(() => source().map/filter/reduce(...)) patterns. import { computedMap, computedFilter, computedReduce } from '@signals-toolkit/core'; const products = signal([ { name: 'Keyboard', price: 120, inStock: true }, { name: 'Monitor', price: 350, inStock: false }, { name: 'Mouse', price: 45, inStock: true }, ]); // Transform const withTax = computedMap(products, p => ({ ...p, total: p.price * 1.19 })); // Filter const available = computedFilter(products, p => p.inStock); // Aggregate const subtotal = computedReduce(products, (sum, p) => sum + p.price, 0); console.log(available()); // [Keyboard, Mouse] console.log(subtotal()); // 515 The syntax is intentional — you read it left to right: "filter products where inStock is true". Much clearer than a nested computed(() => products().filter(...)). Before this, every search input required a manual timer: // The old way let timer: ReturnType | null = null; onInput(value: string) { if (timer) clearTimeout(timer); timer = setTimeout(() => { searchSignal.set(value); timer = null; }, 500); } Now: import { debounceSignal } from '@signals-toolkit/core'; const search = debounceSignal('', 500); // In the template: // effect(() => { this.fetchResults(search()); // fires 500ms after the user stops typing }); The signal handles the timer internally. No cleanup, no clearTimeout, no extra variables. It also supports leading and trailing options for edge cases: // Fire immediately on first keystroke, then silence for 300ms const search = debounceSignal('', { delay: 300, leading: true, trailing: false }); We're not all rewriting RxJS apps overnight. This bridges the gap: import { signalFromObservable } from '@signals-toolkit/core'; const { value, loading, error, destroy } = signalFromObservable( this.http.get('/api/users'), [] // initial value while loading ); You get three signals automatically: the data, loading state, and any error. Use them directly in the template: @if (loading()) { } @else if (error()) { } @else { } Call destroy() in ngOnDestroy to unsubscribe cleanly. This one handles the hardest part of async data: what happens when the source changes while a request is still in flight. import { signal, inject, Injector } from '@angular/core'; import { computedAsync } from '@signals-toolkit/core'; const userId = signal(1); const injector = inject(Injector); const { value, loading, error } = computedAsync( userId, (id, abortSignal) => fetch(`/api/users/${id}`, { abortSignal }).then(r => r.json()), { initialValue: null, injector } ); When userId changes: The previous fetch is aborted via AbortController loading() becomes true The new fetch runs value() updates when it resolves No manual request ID tracking. No stale data. Works with any fetch call out of the box. Need to group related signals? Instead of declaring them one by one: // Before const name = signal(''); const email = signal(''); const role = signal('editor'); // ...then manually reset each one // After import { signalGroup } from '@signals-toolkit/core'; const form = signalGroup({ name: '', email: '', role: 'editor' as 'admin' | 'editor', }); // Each key is a full WritableSignal form.name.set('Felipe'); form.email.set('[email protected]'); // Utilities included form.patch({ role: 'admin' }); // update specific fields form.snapshot(); // { name, email, role } as plain object form.reset(); // back to initial values It's not a full state management solution — it's just the pattern you'd write anyway, with snapshot, reset, and patch already wired up. 14 helpers total 80 unit tests, 0 TypeScript errors 8.8KB on npm (zero bundled dependencies — Angular and RxJS are peer deps) Targets Angular 16+ npm install @signals-toolkit/core Then import whatever you need: import { signalStorage, computedMap, debounceSignal, computedAsync, signalGroup, } from '@signals-toolkit/core'; npm: npmjs.com/package/@signals-toolkit/core GitHub: github.com/piipe800/signals-toolkit Live demo: StackBlitz (link in the repo README) Full API reference: docs/API.md What's next FASE 2 is already planned: More computedAsync options (retry, polling) signalHistory — undo/redo support for any signal signalBus — typed event bus built on signals If you try the library and find something missing or broken, open an issue. Contributions are welcome. What patterns are you seeing repeat across your Angular Signals code? I'd love to know what helpers would actually be useful.