A comprehensive utility library for Angular Signals that provides enhanced functionality, operators, and utilities
npm install ngx-signal-plusbash
npm install ngx-signal-plus
`
Requirements
- Angular >= 16.0.0 (fully compatible with Angular 16-20)
- TypeScript >= 5.0.0
Basic Usage
`typescript
import { Component } from "@angular/core";
import { sp, enhance, spMap, spFilter } from "ngx-signal-plus";
import { signal, computed } from "@angular/core";
@Component({
standalone: true,
selector: "app-counter",
template:
,
})
export class CounterComponent {
// Create an enhanced signal with persistence and history
counter = sp(0)
.persist("counter")
.withHistory(10)
.validate((value) => value >= 0)
.build();
// Use signal operators
doubled = computed(() => this.counter.value() * 2);
increment() {
this.counter.setValue(this.counter.value() + 1);
}
decrement() {
if (this.counter.value() > 0) {
this.counter.setValue(this.counter.value() - 1);
}
}
}
`
Core Features
$3
`typescript
import { sp, spCounter, spToggle, spForm } from "ngx-signal-plus";
// Simple enhanced signal
const name = sp("John").build();
// Counter with min/max validation
const counter = spCounter(0, { min: 0, max: 100 });
// Toggle (boolean) with persistence
const darkMode = spToggle(false, "theme-mode");
// Form input with validation
const username = spForm.text("", {
minLength: 3,
maxLength: 20,
debounce: 300,
});
`
$3
Enhance existing signals with additional features:
`typescript
import { enhance } from "ngx-signal-plus";
import { signal } from "@angular/core";
const enhanced = enhance(signal(0))
.persist("counter")
.validate((n) => n >= 0)
.transform(Math.round)
.withHistory(5)
.debounce(300)
.distinctUntilChanged()
.build();
`
$3
Create computed signals with persistence, history, and validation:
`typescript
import { spComputed } from "ngx-signal-plus";
import { signal } from "@angular/core";
const firstName = signal("John");
const lastName = signal("Doe");
// Computed signal with history and persistence
const fullName = spComputed(() => ${firstName()} ${lastName()}, { persist: "user-fullname", historySize: 5 });
fullName.value; // 'John Doe'
firstName.set("Jane");
fullName.value; // 'Jane Doe' (auto-updates)
fullName.undo(); // 'John Doe'
fullName.isValid(); // true
`
$3
`typescript
import { spMap, spFilter, spDebounceTime, spCombineLatest } from "ngx-signal-plus";
import { signal } from "@angular/core";
// Transform values
const price = signal(100);
const withTax = price.pipe(
spMap((n) => n * 1.2),
spMap((n) => Math.round(n * 100) / 100),
);
// Combine signals
const firstName = signal("John");
const lastName = signal("Doe");
const fullName = spCombineLatest([firstName, lastName]).pipe(spMap(([first, last]) => ${first} ${last}));
`
$3
`typescript
import { spForm } from "ngx-signal-plus";
import { computed } from "@angular/core";
// Form inputs with validation
const username = spForm.text("", { minLength: 3, maxLength: 20 });
const email = spForm.email("");
const age = spForm.number({ min: 18, max: 99, initial: 30 });
// Form validation
const isFormValid = computed(() => username.isValid() && email.isValid() && age.isValid());
`
$3
Group multiple form controls together with aggregated state, validation, and persistence:
`typescript
import { spFormGroup, spForm } from "ngx-signal-plus";
// Basic form group
const loginForm = spFormGroup({
email: spForm.email(""),
password: spForm.text("", { minLength: 8 }),
});
// Access aggregated state
loginForm.isValid(); // false if password < 8 chars
loginForm.isDirty(); // true if any field changed
loginForm.isTouched(); // true if any field touched
loginForm.value(); // { email: '', password: '' }
loginForm.errors(); // { email: [...], password: [...] }
// Update values
loginForm.setValue({ email: "user@example.com", password: "secret123" });
loginForm.patchValue({ email: "new@example.com" }); // Partial update
// Form actions
loginForm.reset(); // Reset all fields to initial values
loginForm.markAsTouched(); // Mark all fields as touched
loginForm.submit(); // Returns values if valid, null otherwise
// Nested form groups
const credentials = spFormGroup({
email: spForm.email(""),
password: spForm.text("", { minLength: 8 }),
});
const profile = spFormGroup({
name: spForm.text(""),
age: spForm.number({ min: 18 }),
});
const registrationForm = spFormGroup({
credentials,
profile,
});
// Group-level validation
const passwordForm = spFormGroup(
{
password: spForm.text("password123"),
confirmPassword: spForm.text("password123"),
},
{
validators: [(values) => values.password === values.confirmPassword || "Passwords must match"],
},
);
// Persistence
const persistedForm = spFormGroup(
{
email: spForm.email(""),
preferences: spForm.text(""),
},
{
persistKey: "user-form", // Automatically saves/restores from localStorage
},
);
`
$3
Manage asynchronous operations with built-in loading, error, and data states:
`typescript
import { spAsync } from "ngx-signal-plus";
const userData = spAsync({
fetcher: () => fetch("/api/user").then((r) => r.json()),
initialValue: null,
retryCount: 3,
retryDelay: 1000,
cacheTime: 5000,
autoFetch: true,
onSuccess: (data) => console.log("Loaded:", data),
onError: (error) => console.error("Failed:", error),
});
// Reactive state signals
userData.data(); // Signal
userData.loading(); // Signal
userData.error(); // Signal
userData.isSuccess(); // Signal
userData.isError(); // Signal
// Methods
await userData.refetch(); // Manually refetch data
userData.invalidate(); // Mark cache as stale
userData.reset(); // Reset to initial state
userData.mutate(newData); // Optimistic update
`
$3
`typescript
import { QueryClient, setGlobalQueryClient } from "ngx-signal-plus";
import { spQuery, spMutation } from "ngx-signal-plus";
const qc = new QueryClient();
setGlobalQueryClient(qc);
const todosQuery = spQuery({
queryKey: ["todos"],
queryFn: async () => fetch("/api/todos").then((r) => r.json()),
staleTime: 5000,
refetchOnWindowFocus: true,
});
const addTodo = spMutation({
mutationFn: async (title: string) => postTodo(title),
onMutate: (title) => {
qc.setQueryData(["todos"], (prev) => [...((prev as { title: string }[] | undefined) ?? []), { title }], true);
},
onSuccess: () => qc.refetchQueries(["todos"]),
});
`
Highlights:
- Cache-aware queries with invalidation and refetch
- Mutations with optimistic updates
- Interval/focus/reconnect refetch strategies
$3
Manage arrays of entities with ID-based operations, optimized updates, and history support:
`typescript
import { spCollection } from "ngx-signal-plus";
interface Todo {
id: string;
title: string;
completed: boolean;
}
const todos = spCollection({
idField: "id",
initialValue: [],
persist: "todos-key",
withHistory: true,
});
// CRUD operations
todos.add({ id: "1", title: "Learn Angular", completed: false });
todos.addMany([todo1, todo2, todo3]);
todos.update("1", { completed: true });
todos.updateMany([
{ id: "1", changes: { completed: true } },
{ id: "2", changes: { title: "Updated" } },
]);
todos.remove("1");
todos.removeMany(["1", "2"]);
todos.clear();
// Query operations
const todo = todos.findById("1");
const completed = todos.filter((t) => t.completed);
const firstCompleted = todos.find((t) => t.completed);
const hasCompleted = todos.some((t) => t.completed);
const allCompleted = todos.every((t) => t.completed);
// Transform operations
const sorted = todos.sort((a, b) => a.title.localeCompare(b.title));
const titles = todos.map((t) => t.title);
const totalCompleted = todos.reduce((acc, t) => acc + (t.completed ? 1 : 0), 0);
// History operations
todos.undo(); // Undo last operation
todos.redo(); // Redo last undone operation
todos.canUndo(); // Check if undo is available
todos.canRedo(); // Check if redo is available
// Reactive signals
todos.value(); // Signal
todos.count(); // Signal
todos.isEmpty(); // Signal
`
$3
`typescript
import { spValidators, spPresets } from "ngx-signal-plus";
// Use built-in validators
const email = sp("").validate(spValidators.string.required).validate(spValidators.string.email).build();
// Use presets for common patterns
const counter = spPresets.counter({
initial: 0,
min: 0,
max: 100,
step: 1,
withHistory: true,
});
const darkMode = spPresets.toggle({
initial: false,
persistent: true,
storageKey: "theme-mode",
});
`
$3
Use any schema validation library with signals:
`typescript
import { sp, spSchema, spSchemaValidator } from "ngx-signal-plus";
import { z } from "zod";
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18),
});
// Basic boolean validation with SignalBuilder
const user = sp({ name: "", email: "", age: 0 }).validate(spSchema(userSchema)).build();
// Advanced: Get detailed error messages
const validator = spSchemaValidator(userSchema);
const result = validator.validateWithErrors({ name: "", email: "invalid", age: 10 });
// result: { valid: false, errors: ["name: String must contain at least 1 character(s)", "email: Invalid email", "age: Number must be greater than or equal to 18"] }
// Use with SignalBuilder for boolean validation
const validatedSignal = sp({ name: "", email: "", age: 0 }).validate(validator.validate).build();
`
$3
Intercept signal operations for logging, analytics, and error tracking:
`typescript
import { spUseMiddleware, spLoggerMiddleware, spAnalyticsMiddleware } from "ngx-signal-plus";
// Built-in logger middleware
spUseMiddleware(spLoggerMiddleware("[DEBUG]"));
// Custom analytics middleware
spUseMiddleware(
spAnalyticsMiddleware((event) => {
analytics.track("signal_change", event);
}),
);
// Custom middleware
spUseMiddleware({
name: "error-tracker",
onSet: (ctx) => console.log(${ctx.signalName}: ${ctx.oldValue} -> ${ctx.newValue}),
onError: (error) => Sentry.captureException(error),
});
`
$3
`typescript
import { spStorageManager, sp } from "ngx-signal-plus";
// Storage management (saves to localStorage with namespace prefix)
spStorageManager.save("app-settings", { theme: "dark", language: "en" });
const settings = spStorageManager.load<{ theme: string; language: string }>("app-settings");
// Remove when no longer needed
spStorageManager.remove("app-settings");
// History management through signals
const counter = sp(0)
.withHistory(10) // Keep last 10 values
.build();
counter.setValue(1);
counter.setValue(2);
counter.setValue(3);
// Navigate history
counter.undo(); // Back to 2
counter.undo(); // Back to 1
counter.redo(); // Forward to 2
// Check history
console.log(counter.history()); // Array of past values
`
$3
ngx-signal-plus provides automatic and manual cleanup to prevent memory leaks:
`typescript
import { sp } from "ngx-signal-plus";
// Automatic cleanup when all subscribers unsubscribe
const signal = sp(0).persist("counter").debounce(300).build();
const unsubscribe = signal.subscribe((value) => console.log(value));
// When you're done with the signal
unsubscribe(); // Automatically cleans up when last subscriber unsubscribes
// Manual cleanup with destroy()
const signal2 = sp(0).persist("data").withHistory(10).build();
signal2.setValue(42);
// Explicitly destroy and clean up all resources
signal2.destroy(); // Removes event listeners, clears timers, frees memory
`
What gets cleaned up:
- ✅ Storage event listeners (for localStorage synchronization)
- ✅ Debounce/throttle timers
- ✅ All subscribers
- ✅ Pending operations
SSR-Safe: All cleanup operations work safely in server-side rendering environments.
$3
Group multiple updates together with automatic rollback on errors:
`typescript
import { spTransaction, spBatch } from "ngx-signal-plus";
const balance = sp(100).build();
const cart = sp([]).build();
// Transaction with automatic rollback
try {
spTransaction(() => {
balance.setValue(balance.value() - 50);
cart.update((items) => [...items, "premium-item"]);
if (balance.value() < 0) {
throw new Error("Insufficient funds");
}
// Success - changes are committed
});
} catch (error) {
// Error - all changes automatically rolled back
console.log(balance.value()); // 100 (original value)
console.log(cart.value()); // [] (original value)
}
// Batch updates for performance (no rollback)
spBatch(() => {
signal1.setValue(1);
signal2.setValue(2);
signal3.setValue(3);
// All changes applied together efficiently
});
`
$3
The library works seamlessly with Angular Universal:
`typescript
// This code works in both SSR and browser
const userPrefs = sp({ theme: "dark" }).persist("user-preferences").build();
// In SSR: works in-memory, localStorage calls are safely skipped
// In browser: full persistence with localStorage
`
What happens during SSR:
- Signals work normally with in-memory state
- localStorage operations are safely skipped (no errors)
- State automatically persists once the app runs in the browser
Available Features
| Category | Features |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Signal Creation | sp, spCounter, spToggle, spForm, spComputed |
| Signal Enhancement | enhance, validation, transformation, persistence, history |
| Signal Operators | spMap, spFilter, spDebounceTime, spThrottleTime, spDelay, spDistinctUntilChanged, spSkip, spTake, spMerge, spCombineLatest |
| Form Groups | spFormGroup - Group multiple controls with aggregated state, validation, and persistence |
| Async State Management | spAsync - Manage asynchronous operations with loading, error, retry, and caching |
| Collection Management | spCollection - Manage arrays of entities with ID-based CRUD, queries, transforms, and history |
| Transactions & Batching | spTransaction, spBatch, spIsTransactionActive, spIsInTransaction, spIsInBatch, spGetModifiedSignals |
| Utilities | spValidators, spPresets, spSchema, spSchemaValidator |
| Middleware/Plugins | spUseMiddleware, spRemoveMiddleware, spLoggerMiddleware, spAnalyticsMiddleware |
| State Management | spHistoryManager, spStorageManager |
| Components | spSignalPlusComponent, spSignalPlusService, spSignalBuilder |
Bundle Size Optimization
The library is built with tree-shaking and optimization in mind. You only pay for what you use.
$3
The package provides modular exports for selective importing:
`typescript
// Import only what you need - tree-shaking removes unused code
// Core signals only (~3KB gzipped)
import { sp, spCounter, spToggle } from "ngx-signal-plus/core";
// Operators only (~2KB gzipped)
import { spMap, spFilter, spDebounceTime } from "ngx-signal-plus/operators";
// Utilities only (~2KB gzipped)
import { enhance, spValidators, spPresets } from "ngx-signal-plus/utils";
// State managers (~1KB gzipped)
import { spHistoryManager, spStorageManager } from "ngx-signal-plus";
// Everything (~8KB gzipped)
import { sp, spMap, spFilter, enhance, spValidators } from "ngx-signal-plus";
`
$3
The package is optimized for tree-shaking:
- ✅ sideEffects: false in package.json - marks the library as side-effect free
- ✅ Modular exports - separate entry points for each feature category
- ✅ ES2022 modules - modern JavaScript with full tree-shaking support
- ✅ FESM bundles - Flat ESM bundles for better optimization
- ✅ Individual entry points for granular control:
- ngx-signal-plus/core - Core signal creation
- ngx-signal-plus/operators - Signal operators
- ngx-signal-plus/utils - Utilities and validators
- ngx-signal-plus/models - TypeScript types
$3
1. Import only what you need:
`typescript
// ✅ Good - imports only used features
import { sp, spCounter } from "ngx-signal-plus";
// ❌ Avoid - imports everything even if unused
import * as SignalPlus from "ngx-signal-plus";
`
2. Use named imports:
`typescript
// ✅ Good - tree-shaking can remove unused exports
import { sp, spMap } from "ngx-signal-plus";
// ❌ Less optimal - may import more than needed
import SignalPlus from "ngx-signal-plus";
`
3. Import from specific entry points:
`typescript
// ✅ Good - direct import from feature module
import { spMap, spFilter } from "ngx-signal-plus/operators";
// ✅ Also good - barrel export handles tree-shaking
import { spMap, spFilter } from "ngx-signal-plus";
`
$3
| Feature Set | Size (gzipped) | Savings vs Full |
| --------------- | -------------- | --------------- |
| Just sp()` | ~1.5 KB | -87% |