--- url: /ng-reactive-utils/getting-started/ai-integration.md --- # AI Integration NG Reactive Utils is designed as an AI-first library with multiple integration options for AI coding assistants. ## Agent Skills (Recommended) Install ng-reactive-utils knowledge into your AI coding agent with a single command: ```bash npx skills add neb636/ng-reactive-utils ``` This works with **33+ AI coding agents** including: * Cursor * Claude Code * GitHub Copilot * Windsurf * Codex * Cline * OpenCode * And many more After installation, your AI agent will automatically: * Suggest ng-reactive-utils composables instead of RxJS subscriptions * Know the complete API and usage patterns * Follow best practices for signal-based Angular development ### Supported Agents | Agent | Project Path | Global Path | |-------|--------------|-------------| | Cursor | `.cursor/skills/` | `~/.cursor/skills/` | | Claude Code | `.claude/skills/` | `~/.claude/skills/` | | GitHub Copilot | `.github/skills/` | `~/.copilot/skills/` | | Windsurf | `.windsurf/skills/` | `~/.codeium/windsurf/skills/` | See the full list at [add-skill.org](https://add-skill.org/). ## llms.txt Standard This documentation implements the [llms.txt standard](https://llmstxt.org/) for AI tool integration. AI assistants can fetch the complete documentation from: ``` https://neb636.github.io/ng-reactive-utils/llms-full.txt ``` Use this with any LLM (ChatGPT, Claude, etc.) by pasting the URL or content into your conversation. ## Cursor IDE Rules (Manual) For Cursor users who prefer manual setup, copy the rules file to your project: ```bash mkdir -p .cursor/rules cp node_modules/ng-reactive-utils/cursor-rules.mdc .cursor/rules/ng-reactive-utils.mdc ``` Or download from [GitHub](https://github.com/neb636/ng-reactive-utils/blob/main/cursor-rules.mdc). ## What AI Integration Provides * Maps common Angular patterns to the appropriate composable * Prevents unnecessary RxJS subscriptions * Provides real-world usage examples * Teaches anti-patterns to avoid ## Example Prompts After installation, use natural language: * "Add form validation status display" * "Track the route parameter" * "Add debounced search" * "Persist this setting to localStorage" * "Make this responsive to window size" Your AI assistant will automatically suggest ng-reactive-utils solutions. --- --- url: /ng-reactive-utils/getting-started/core-concepts.md --- # Core Concepts Understanding a few key concepts will help you get the most out of NG Reactive Utils. ## Signals vs Observables Angular's modern reactivity is built on **signals**, which provide synchronous, glitch-free updates. Observables (RxJS) are asynchronous and stream-based. **When to use signals:** * Component state that needs to trigger UI updates * Derived/computed values * Simple reactive state management **When to use observables:** * Async operations (HTTP requests, timers) * Complex event streams with operators * When you need precise control over timing and cancellation **When to use NG Reactive Utils:** * Converting observables to signals (forms, routes) * Reading browser state reactively (window size, mouse position, storage) ## Composables Composables **return** reactive values (signals) that you can use in your templates and logic: ```typescript import { useWindowSize, useRouteParam } from 'ng-reactive-utils'; export class MyComponent { windowSize = useWindowSize(); userId = useRouteParam('id'); // Use in template: {{ windowSize().width }}, {{ userId() }} } ``` **Common composables:** * `useWindowSize()` - Track window dimensions * `useRouteParam()` - Read route parameters * `useFormState()` - Get form state as signals * `usePreviousSignal()` - Track the previous value of a signal * `useLocalStorage()` - Two-way signal sync with localStorage * `whenTrue()` / `whenFalse()` - Run side effects when a signal becomes truthy or falsy ## When to Use NG Reactive Utils vs Vanilla Angular ### Use NG Reactive Utils when: ✅ Converting form observables to signals ```typescript // Instead of: toSignal(form.valueChanges, { initialValue: form.value }) formState = useFormState(this.form); ``` ✅ Converting route observables to signals ```typescript // Instead of: toSignal(route.params.pipe(map(...)), { initialValue: ... }) userId = useRouteParam('id'); ``` ✅ Reacting to browser state ```typescript // Instead of: manual fromEvent(window, 'resize') with toSignal() windowSize = useWindowSize(); isMobile = computed(() => this.windowSize().width < 768); ``` ✅ Running side effects when a signal reaches a specific state ```typescript // Instead of: effect() + manual untracked() to avoid unintended subscriptions onOpen = whenTrue(this.isOpen, () => { this.copy.set(cloneDeep(this.data())); }); onClose = whenFalse(this.isOpen, () => { this.copy.set(null); }); ``` ## Type Safety All utilities are fully typed with TypeScript: ```typescript interface UserForm { email: string; age: number; } // Type inference works automatically formState = useFormState(this.form); formState.value(); // { email: string; age: number } // Route params with types params = useRouteParams<{ id: string; postId: string }>(); params(); // { id: string; postId: string } ``` ## Memory Management Signals created by NG Reactive Utils are automatically cleaned up when the component is destroyed: ```typescript export class MyComponent { // Automatically cleaned up on component destroy windowSize = useWindowSize(); theme = useLocalStorage('theme', 'light'); } ``` No manual cleanup needed — Angular handles it through the injection context. ## Next Steps * Explore [Browser Composables](/composables/browser/use-window-size) * Check out [Form Composables](/composables/form/use-form-state) --- --- url: /ng-reactive-utils/utils/create-shared-composable.md --- # createSharedComposable Creates a shared instance of a wrapped composable function that uses reference counting. When the last consumer is destroyed, the shared instance and its resources are cleaned up automatically. ## Usage ### Basic Usage Without Parameters ```typescript import { createSharedComposable } from 'ng-reactive-utils'; import { signal } from '@angular/core'; const useWebSocket = createSharedComposable(() => { const socket = new WebSocket('wss://api.example.com'); const messages = signal([]); socket.onmessage = (event) => { messages.update((m) => [...m, event.data]); }; return { value: messages.asReadonly(), cleanup: () => socket.close(), }; }); @Component({ template: `
{{ messages() | json }}
`, }) class ChatComponent { messages = useWebSocket(); } ``` ### With Parameters ```typescript import { createSharedComposable } from 'ng-reactive-utils'; import { inject, signal } from '@angular/core'; import { DOCUMENT } from '@angular/common'; const useMediaQuery = createSharedComposable((query: string) => { const document = inject(DOCUMENT); const mediaQuery = document.defaultView?.matchMedia(query); const matches = signal(mediaQuery?.matches ?? false); const handleChange = (event: MediaQueryListEvent) => matches.set(event.matches); mediaQuery?.addEventListener('change', handleChange); return { value: matches.asReadonly(), cleanup: () => mediaQuery?.removeEventListener('change', handleChange), }; }); @Component({ template: ``, }) class NavComponent { isMobile = useMediaQuery('(max-width: 768px)'); } ``` ## Parameters | Parameter | Type | Description | | --------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `factory` | `(...args: Args) => ComposableResult` | A factory function that creates the composable instance. Must return an object with `value` and optional `cleanup` function. | ## Returns A function `(...args: Args) => T` that returns the shared instance value. The function can be called with the same arguments as the factory function. ## ComposableResult Interface The factory function must return an object with: * `value: T` - The value to be shared across all consumers * `cleanup?: () => void` - Optional cleanup function called when the last consumer is destroyed ## Notes * **Reference Counting**: Multiple components using the same composable with the same arguments share a single instance * **Automatic Cleanup**: When the last consumer component is destroyed, the cleanup function is called automatically * **Argument-Based Caching**: Different argument values create separate cached instances * **DestroyRef Integration**: Automatically registers cleanup handlers using Angular's `DestroyRef` * **Memory Efficient**: Prevents resource leaks by ensuring cleanup runs when no more consumers exist * Use this utility when you want to share expensive resources (like WebSocket connections, event listeners, or API subscriptions) across multiple components ## Source ```ts import { DestroyRef, inject } from '@angular/core'; interface ComposableResult { value: T; cleanup?: () => void; } interface CacheEntry { result: T; refCount: number; cleanup?: () => void; } /** * Creates a shared instance of a wrapped composable function that uses reference counting. * When the last consumer is destroyed, the shared instance and its resources are cleaned up automatically. * * @example * // Basic usage without parameters * const useWebSocket = createSharedComposable(() => { * const socket = new WebSocket('wss://api.example.com'); * const messages = signal([]); * * socket.onmessage = (event) => { * messages.update((m) => [...m, event.data]); * }; * * return { * value: messages.asReadonly(), * cleanup: () => socket.close(), * }; * }); * * @example * // With parameters * const useMediaQuery = createSharedComposable((query: string) => { * const document = inject(DOCUMENT); * const mediaQuery = document.defaultView?.matchMedia(query); * const matches = signal(mediaQuery?.matches ?? false); * * const handleChange = (event: MediaQueryListEvent) => matches.set(event.matches); * mediaQuery?.addEventListener('change', handleChange); * * return { * value: matches.asReadonly(), * cleanup: () => mediaQuery?.removeEventListener('change', handleChange), * }; * }); */ export function createSharedComposable( factory: (...args: Args) => ComposableResult, ): (...args: Args) => T { // Cache is scoped to the factory function itself const cache = new Map>(); return (...args: Args): T => { const destroyRef = inject(DestroyRef); // Create cache key from arguments const cacheKey = args.length > 0 ? JSON.stringify(args) : '__default__'; // Get or create cached entry let entry = cache.get(cacheKey); if (!entry) { // Create new instance const result = factory(...args); entry = { result: result.value, refCount: 0, cleanup: result.cleanup, }; cache.set(cacheKey, entry); } // Increment reference count entry.refCount++; // Register cleanup on component destruction destroyRef.onDestroy(() => { if (entry) { entry.refCount--; // If no more references, cleanup and remove from cache if (entry.refCount <= 0) { entry.cleanup?.(); cache.delete(cacheKey); } } }); return entry.result; }; } ``` --- --- url: /ng-reactive-utils/getting-started/installation.md --- # Installation ## Requirements * **Angular 20+** (for full signal support) * **Node.js 22.20.0+** * **TypeScript** with strict mode recommended ## Install the Package ::: code-group ```bash [npm] npm install ng-reactive-utils ``` ```bash [pnpm] pnpm add ng-reactive-utils ``` ```bash [yarn] yarn add ng-reactive-utils ``` ::: ## Add AI Support (Optional) If you use an AI coding assistant (Cursor, Claude Code, GitHub Copilot, etc.), install the agent skill: ```bash npx skills add neb636/ng-reactive-utils ``` This teaches your AI assistant ng-reactive-utils patterns so it can suggest the right composables and effects. See [AI Integration](/getting-started/ai-integration) for more details. ## Quick Start Examples ### Using a Composable Composables return signals you can use in your templates: ```typescript import { Component, computed } from '@angular/core'; import { useRouteParam, useWindowSize } from 'ng-reactive-utils'; @Component({ selector: 'user-profile', template: `

User {{ userId() }}

@if (isMobile()) { } @else { } `, }) export class UserProfileComponent { userId = useRouteParam('id'); windowSize = useWindowSize(); isMobile = computed(() => this.windowSize().width < 768); } ``` ### Persisting State to Storage Use `useLocalStorage` to keep a signal automatically synced with localStorage: ```typescript import { Component } from '@angular/core'; import { useLocalStorage } from 'ng-reactive-utils'; @Component({ selector: 'preferences', template: ` `, }) export class PreferencesComponent { // Reads from localStorage on init, writes back on every change darkMode = useLocalStorage('dark-mode-preference', false); } ``` ### Working with Forms Convert reactive forms to signals without boilerplate: ```typescript import { Component } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { useFormState } from 'ng-reactive-utils'; @Component({ selector: 'user-form', template: `
@if (formState.invalid() && formState.touched()) {
Please enter a valid email
}
`, }) export class UserFormComponent { form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email]), }); formState = useFormState<{ email: string }>(this.form); // Access: formState.value(), formState.valid(), formState.dirty(), etc. } ``` ## Next Steps Now that you have the library installed: 1. **[Core Concepts](/getting-started/core-concepts)** - Understand composables vs effects 2. **[Browse APIs](/composables/browser/use-window-size)** - Explore all available utilities --- --- url: /ng-reactive-utils/getting-started/introduction.md --- # Introduction NG Reactive Utils is a collection of composables for modern Angular applications. These utilities eliminate boilerplate and make working with signals more productive. ## What is it? A utility library that provides: * **Form & Route utilities** - Convert observables to signals without repetitive `toSignal()` calls * **Browser APIs** - Window size, mouse position, storage, media queries, and more * **Signal utilities** - Track previous values, react to signal state changes, and share composable instances across components ## Quick Example Read a route parameter and react to window size with one line each: ```typescript import { Component, computed } from '@angular/core'; import { useRouteParam, useWindowSize } from 'ng-reactive-utils'; @Component({ selector: 'user-profile', template: `

User: {{ userId() }}

@if (isMobile()) { } @else { } `, }) export class UserProfileComponent { userId = useRouteParam('id'); windowSize = useWindowSize(); isMobile = computed(() => this.windowSize().width < 768); } ``` ## Why Use It? **Without NG Reactive Utils:** ```typescript // Repetitive toSignal() calls everywhere formValue = toSignal(form.valueChanges, { initialValue: form.value }); formValid = toSignal(form.statusChanges.pipe(map(() => form.valid)), { initialValue: form.valid }); userId = toSignal(route.params.pipe(map(p => p['id'])), { initialValue: route.snapshot.params['id'] }); ``` **With NG Reactive Utils:** ```typescript // Clean, readable utilities formState = useFormState(this.form); // value(), valid(), dirty(), etc. userId = useRouteParam('id'); ``` **Without NG Reactive Utils:** ```typescript // Noisy effects with easy-to-forget untracked() effect(() => { if (this.isOpen()) { untracked(() => { this.copy.set(cloneDeep(this.data())); }); } }); ``` **With NG Reactive Utils:** ```typescript // Intent is obvious, untracked handled automatically onOpen = whenTrue(this.isOpen, () => { this.copy.set(cloneDeep(this.data())); }); ``` ## Key Benefits * **Less boilerplate** - Replace repetitive `toSignal()` calls with clean utilities * **Type-safe** - Full TypeScript support with proper inference * **Signal-first** - Built for Angular's modern reactivity system * **Tree-shakable** - Import only what you need ## What's Available * **[Browser Composables](/composables/browser/use-window-size)** - Window size, mouse position, document visibility, storage * **[General Composables](/composables/general/use-previous-signal)** - Previous signal value tracking, declarative side effect helpers * **[Form Composables](/composables/form/use-form-state)** - Form state as signals * **[Route Composables](/composables/route/use-route-param)** - Route params, query params, data as signals ## Next Steps Ready to get started? 1. [Install the library](/getting-started/installation) 2. Understand [core concepts](/getting-started/core-concepts) 3. Explore the available composables --- --- url: /ng-reactive-utils/composables/control/use-control-dirty.md --- # useControlDirty Returns whether an AbstractControl is dirty (has been modified) as a signal. The signal updates reactively whenever the control's dirty state changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlDirty } from 'ng-reactive-utils'; @Component({ template: ` @if (isDirty()) { * } `, }) class EditableFieldComponent { nameControl = new FormControl(''); isDirty = useControlDirty(this.nameControl); } ``` ## Advanced Usage ```typescript import { useControlDirty } from 'ng-reactive-utils'; @Component({ template: ` @if (hasChanges()) { } `, }) class DocumentEditorComponent { titleControl = new FormControl(''); contentControl = new FormControl(''); titleDirty = useControlDirty(this.titleControl); contentDirty = useControlDirty(this.contentControl); hasChanges = computed(() => this.titleDirty() || this.contentDirty()); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | ------------------------------------ | | `control` | `AbstractControl` | *required* | The control to check dirty state for | ## Returns `Signal` - A readonly signal containing the dirty state (true if modified) ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.valueChanges` observable * Returns `true` when the control value has been changed * Returns `false` when the control is pristine (unchanged) * Useful for tracking unsaved changes ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether an AbstractControl is dirty (has been modified) as a signal. * The signal updates reactively whenever the control's dirty state changes. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to check dirty state for * @returns A signal containing the dirty state (true if modified) * * @example * ```typescript * @Component({ * template: ` * * @if (isDirty()) { * Field has been modified * } * ` * }) * class MyComponent { * nameControl = new FormControl(''); * isDirty = useControlDirty(this.nameControl); * } * ``` */ export const useControlDirty = (control: AbstractControl): Signal => { return toSignal(control.valueChanges.pipe(map(() => control.dirty)), { initialValue: control.dirty, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-disabled.md --- # useControlDisabled Returns whether an AbstractControl is disabled as a signal. The signal updates reactively whenever the control's disabled state changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlDisabled } from 'ng-reactive-utils'; @Component({ template: ` @if (isDisabled()) { This field is currently disabled } `, }) class ToggleableInputComponent { emailControl = new FormControl(''); isDisabled = useControlDisabled(this.emailControl); toggleDisabled() { if (this.emailControl.disabled) { this.emailControl.enable(); } else { this.emailControl.disable(); } } } ``` ## Advanced Usage ```typescript import { useControlDisabled } from 'ng-reactive-utils'; @Component({ template: ` @if (backupDisabled()) {

Enter primary email first

} `, }) class EmailFormComponent { primaryEmail = new FormControl('', Validators.required); backupEmail = new FormControl({ value: '', disabled: true }); primaryValue = useControlValue(this.primaryEmail); backupDisabled = useControlDisabled(this.backupEmail); constructor() { // Enable backup email when primary is filled effect(() => { if (this.primaryValue()) { this.backupEmail.enable(); } else { this.backupEmail.disable(); } }); } } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | --------------------------------------- | | `control` | `AbstractControl` | *required* | The control to check disabled state for | ## Returns `Signal` - A readonly signal containing the disabled state (true if disabled) ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.statusChanges` observable * Returns `true` when the control is disabled * Disabled controls are excluded from the form's value * Disabled controls skip validation ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether an AbstractControl is disabled as a signal. * The signal updates reactively whenever the control's disabled state changes. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to check disabled state for * @returns A signal containing the disabled state (true if disabled) * * @example * ```typescript * @Component({ * template: ` * * @if (isDisabled()) { * This field is currently disabled * } * ` * }) * class MyComponent { * emailControl = new FormControl(''); * isDisabled = useControlDisabled(this.emailControl); * * disableField() { * this.emailControl.disable(); * } * } * ``` */ export const useControlDisabled = (control: AbstractControl): Signal => { return toSignal(control.statusChanges.pipe(map(() => control.disabled)), { initialValue: control.disabled, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-errors.md --- # useControlErrors Returns the validation errors of an AbstractControl as a signal. The signal updates reactively whenever the control's validation errors change. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlErrors } from 'ng-reactive-utils'; @Component({ template: ` @if (errors()?.['required']) { Email is required } @if (errors()?.['email']) { Please enter a valid email address } @if (errors()?.['minlength']) { Email must be at least {{ errors()?.['minlength'].requiredLength }} characters } `, }) class EmailFieldComponent { emailControl = new FormControl('', [ Validators.required, Validators.email, Validators.minLength(5), ]); errors = useControlErrors(this.emailControl); } ``` ## Advanced Usage ```typescript import { useControlErrors } from 'ng-reactive-utils'; @Component({ template: `
    @for (error of errorMessages(); track error) {
  • {{ error }}
  • }
`, }) class PasswordFieldComponent { passwordControl = new FormControl('', [ Validators.required, Validators.minLength(8), Validators.pattern(/[A-Z]/), Validators.pattern(/[0-9]/), ]); errors = useControlErrors(this.passwordControl); errorMessages = computed(() => { const errors = this.errors(); if (!errors) return []; const messages: string[] = []; if (errors['required']) messages.push('Password is required'); if (errors['minlength']) messages.push('Must be at least 8 characters'); if (errors['pattern']) messages.push('Must contain uppercase and numbers'); return messages; }); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | ------------------------------ | | `control` | `AbstractControl` | *required* | The control to get errors from | ## Returns `Signal` - A readonly signal containing the validation errors or null if no errors ## Common Error Types | Error | Description | | ----------- | ------------------------------- | | `required` | Value is empty | | `email` | Value is not a valid email | | `minlength` | Value is shorter than required | | `maxlength` | Value is longer than allowed | | `pattern` | Value doesn't match the pattern | | `min` | Numeric value is below minimum | | `max` | Numeric value is above maximum | ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.statusChanges` observable * Returns `null` when the control has no validation errors * Error object keys correspond to validator names ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl, ValidationErrors } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns the validation errors of an AbstractControl as a signal. * The signal updates reactively whenever the control's validation errors change. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to get errors from * @returns A signal containing the validation errors or null if no errors * * @example * ```typescript * @Component({ * template: ` * * @if (errors()?.['required']) { * Email is required * } * @if (errors()?.['email']) { * Please enter a valid email * } * ` * }) * class MyComponent { * emailControl = new FormControl('', [Validators.required, Validators.email]); * errors = useControlErrors(this.emailControl); * } * ``` */ export const useControlErrors = (control: AbstractControl): Signal => { return toSignal(control.statusChanges.pipe(map(() => control.errors)), { initialValue: control.errors, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-pending.md --- # useControlPending Returns whether an AbstractControl has pending async validators as a signal. The signal updates reactively whenever the control's pending state changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlPending } from 'ng-reactive-utils'; @Component({ template: ` @if (isPending()) { Checking availability... } `, }) class UsernameFieldComponent { usernameControl = new FormControl('', [], [asyncUsernameValidator]); isPending = useControlPending(this.usernameControl); } ``` ## Advanced Usage ```typescript import { useControlPending } from 'ng-reactive-utils'; @Component({ template: ` @if (isPending()) {
Validating email...
} @else if (emailControl.valid) {
Email is available
} `, }) class EmailVerificationComponent { emailControl = new FormControl('', [Validators.email], [asyncEmailValidator]); isPending = useControlPending(this.emailControl); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | -------------------------------------- | | `control` | `AbstractControl` | *required* | The control to check pending state for | ## Returns `Signal` - A readonly signal containing the pending state (true if async validators are running) ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.statusChanges` observable * Returns `true` when the control has pending async validators * Returns `false` when all validators have completed * Useful for showing loading indicators during async validation ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether an AbstractControl has pending async validators as a signal. * The signal updates reactively whenever the control's pending state changes. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to check pending state for * @returns A signal containing the pending state (true if async validators are running) * * @example * ```typescript * @Component({ * template: ` * * @if (isPending()) { * Checking availability... * } * ` * }) * class MyComponent { * usernameControl = new FormControl('', [], [asyncUsernameValidator]); * isPending = useControlPending(this.usernameControl); * } * ``` */ export const useControlPending = (control: AbstractControl): Signal => { return toSignal(control.statusChanges.pipe(map(() => control.pending)), { initialValue: control.pending, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-pristine.md --- # useControlPristine Returns whether an AbstractControl is pristine (has not been modified) as a signal. The signal updates reactively whenever the control's pristine state changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlPristine } from 'ng-reactive-utils'; @Component({ template: ` @if (isPristine()) { Start typing to edit } `, }) class EditableFieldComponent { nameControl = new FormControl(''); isPristine = useControlPristine(this.nameControl); } ``` ## Advanced Usage ```typescript import { useControlPristine } from 'ng-reactive-utils'; @Component({ template: ` @if (isPristine()) {

Popular searches

  • Angular signals
  • Reactive forms
} @else {
} `, }) class SearchComponent { searchControl = new FormControl(''); isPristine = useControlPristine(this.searchControl); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | --------------------------------------- | | `control` | `AbstractControl` | *required* | The control to check pristine state for | ## Returns `Signal` - A readonly signal containing the pristine state (true if not modified) ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.valueChanges` observable * Returns `true` when the control value has not been changed * Returns `false` when the control is dirty (has been modified) * Opposite of `useControlDirty` ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether an AbstractControl is pristine (has not been modified) as a signal. * The signal updates reactively whenever the control's pristine state changes. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to check pristine state for * @returns A signal containing the pristine state (true if not modified) * * @example * ```typescript * @Component({ * template: ` * * @if (isPristine()) { * Field has not been modified * } * ` * }) * class MyComponent { * nameControl = new FormControl(''); * isPristine = useControlPristine(this.nameControl); * } * ``` */ export const useControlPristine = (control: AbstractControl): Signal => { return toSignal(control.valueChanges.pipe(map(() => control.pristine)), { initialValue: control.pristine, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-state.md --- # useControlState Converts an AbstractControl into a reactive state object with signals for all control properties. This provides a comprehensive view of the control's state that updates reactively. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlState } from 'ng-reactive-utils'; @Component({ template: ` @if (emailState.invalid() && emailState.touched()) { Please enter a valid email } @if (emailState.dirty()) { Email has been modified }
Value: {{ emailState.value() }}
Status: {{ emailState.status() }}
`, }) class EmailInputComponent { emailControl = new FormControl('', [Validators.required, Validators.email]); emailState = useControlState(this.emailControl); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | --------------------------------- | | `control` | `AbstractControl` | *required* | The control to convert to signals | ## Returns `ControlState` - An object containing signals for all control state properties: | Property | Type | Description | | ----------- | --------------------------- | ----------------------------------------------------------------- | ------------------------------------ | | `value` | `Signal` | The current value of the control | | `status` | `Signal` | The validation status ('VALID', 'INVALID', 'PENDING', 'DISABLED') | | `valid` | `Signal` | Whether the control is valid | | `invalid` | `Signal` | Whether the control is invalid | | `pending` | `Signal` | Whether async validators are running | | `disabled` | `Signal` | Whether the control is disabled | | `enabled` | `Signal` | Whether the control is enabled | | `dirty` | `Signal` | Whether the control has been modified | | `pristine` | `Signal` | Whether the control has not been modified | | `touched` | `Signal` | Whether the control has been interacted with | | `untouched` | `Signal` | Whether the control has not been interacted with | | `errors` | `Signal` | The validation errors of the control | ## Notes * Works with FormControl, FormGroup, and FormArray * Composes all individual control composables (`useControlValue`, `useControlValid`, `useControlTouched`, etc.) into a single convenience object * `invalid` is `computed(() => !valid())` and `enabled` is `computed(() => !disabled())` — they are derived signals, not independent subscriptions, so they are always atomically consistent with their counterparts * All signals update reactively when the control state changes * For individual properties, consider using the specific composables like `useControlValue`, `useControlValid`, etc. to avoid subscribing to all observables when only one is needed ## Source ````ts import { computed, Signal } from '@angular/core'; import { AbstractControl, FormControlStatus, ValidationErrors } from '@angular/forms'; import { useControlValue } from '../use-control-value/use-control-value.composable'; import { useControlStatus } from '../use-control-status/use-control-status.composable'; import { useControlValid } from '../use-control-valid/use-control-valid.composable'; import { useControlPending } from '../use-control-pending/use-control-pending.composable'; import { useControlDisabled } from '../use-control-disabled/use-control-disabled.composable'; import { useControlDirty } from '../use-control-dirty/use-control-dirty.composable'; import { useControlPristine } from '../use-control-pristine/use-control-pristine.composable'; import { useControlTouched } from '../use-control-touched/use-control-touched.composable'; import { useControlUntouched } from '../use-control-untouched/use-control-untouched.composable'; import { useControlErrors } from '../use-control-errors/use-control-errors.composable'; /** * Represents the reactive state of an AbstractControl as signals. */ export interface ControlState { /** The current value of the control */ value: Signal; /** The validation status of the control */ status: Signal; /** Whether the control is valid */ valid: Signal; /** Whether the control is invalid */ invalid: Signal; /** Whether the control has pending async validators */ pending: Signal; /** Whether the control is disabled */ disabled: Signal; /** Whether the control is enabled */ enabled: Signal; /** Whether the control value has been modified */ dirty: Signal; /** Whether the control value has not been modified */ pristine: Signal; /** Whether the control has been interacted with */ touched: Signal; /** Whether the control has not been interacted with */ untouched: Signal; /** The validation errors of the control */ errors: Signal; } /** * Converts an AbstractControl into a reactive state object with signals for all control properties. * This provides a comprehensive view of the control's state that updates reactively. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to convert to signals * @returns An object containing signals for all control state properties * * @example * ```typescript * @Component({ * template: ` * * @if (emailState.invalid() && emailState.touched()) { * Please enter a valid email * } * ` * }) * class MyComponent { * emailControl = new FormControl('', [Validators.required, Validators.email]); * emailState = useControlState(this.emailControl); * } * ``` */ export const useControlState = (control: AbstractControl): ControlState => { const valid = useControlValid(control); const disabled = useControlDisabled(control); return { value: useControlValue(control), status: useControlStatus(control), valid, invalid: computed(() => !valid()), pending: useControlPending(control), disabled, enabled: computed(() => !disabled()), dirty: useControlDirty(control), pristine: useControlPristine(control), touched: useControlTouched(control), untouched: useControlUntouched(control), errors: useControlErrors(control), }; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-status.md --- # useControlStatus Returns the validation status of an AbstractControl as a signal. The signal updates reactively whenever the control's status changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlStatus } from 'ng-reactive-utils'; @Component({ template: ` {{ controlStatus() }} @switch (controlStatus()) { @case ('VALID') { Username is available } @case ('INVALID') { Username is invalid } @case ('PENDING') { Checking availability... } @case ('DISABLED') { Username cannot be changed } } `, styles: ` .status-valid { color: green; } .status-invalid { color: red; } .status-pending { color: orange; } .status-disabled { color: gray; } `, }) class UsernameFieldComponent { usernameControl = new FormControl( '', [Validators.required, Validators.minLength(3)], [this.usernameAvailabilityValidator()], ); controlStatus = useControlStatus(this.usernameControl); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | ------------------------------ | | `control` | `AbstractControl` | *required* | The control to get status from | ## Returns `Signal` - A readonly signal containing the control status ## Status Values | Status | Description | | ---------- | ------------------------------------ | | `VALID` | Control passes all validation | | `INVALID` | Control has validation errors | | `PENDING` | Control has pending async validators | | `DISABLED` | Control is disabled | ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.statusChanges` observable * Useful for conditional rendering based on control state * Status changes trigger when value changes, validators run, or control is disabled/enabled ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl, FormControlStatus } from '@angular/forms'; /** * Returns the validation status of an AbstractControl as a signal. * The signal updates reactively whenever the control's status changes. * Works with FormControl, FormGroup, and FormArray. * * Status values: * - 'VALID': The control is valid * - 'INVALID': The control is invalid * - 'PENDING': The control has pending async validators * - 'DISABLED': The control is disabled * * @param control - The AbstractControl to get status from * @returns A signal containing the control status * * @example * ```typescript * @Component({ * template: ` * * * Status: {{ controlStatus() }} * * ` * }) * class MyComponent { * emailControl = new FormControl('', Validators.required); * controlStatus = useControlStatus(this.emailControl); * } * ``` */ export const useControlStatus = (control: AbstractControl): Signal => { return toSignal(control.statusChanges, { initialValue: control.status, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-touched.md --- # useControlTouched Returns whether an AbstractControl has been touched (interacted with) as a signal. The signal updates reactively whenever the control's touched state changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlTouched } from 'ng-reactive-utils'; @Component({ template: ` @if (isTouched() && emailControl.invalid) { Please enter a valid email } `, }) class EmailInputComponent { emailControl = new FormControl('', [Validators.required, Validators.email]); isTouched = useControlTouched(this.emailControl); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | -------------------------------------- | | `control` | `AbstractControl` | *required* | The control to check touched state for | ## Returns `Signal` - A readonly signal containing the touched state (true if interacted with) ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `control.events` (Angular's unified event stream) filtered to `TouchedChangeEvent` — not `statusChanges`, which does not emit on touched-state changes * Returns `true` when `markAsTouched()` is called or the control loses focus (blur) * Useful for showing validation errors only after user interaction ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl, TouchedChangeEvent } from '@angular/forms'; import { filter, map } from 'rxjs'; /** * Returns whether an AbstractControl has been touched (interacted with) as a signal. * The signal updates reactively whenever the control's touched state changes, * including when markAsTouched() or markAsUntouched() are called directly. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to check touched state for * @returns A signal containing the touched state (true if interacted with) * * @example * ```typescript * @Component({ * template: ` * * @if (isTouched() && emailControl.invalid) { * Please enter a valid email * } * ` * }) * class MyComponent { * emailControl = new FormControl('', Validators.email); * isTouched = useControlTouched(this.emailControl); * } * ``` */ export const useControlTouched = (control: AbstractControl): Signal => { return toSignal( control.events.pipe( filter((event): event is TouchedChangeEvent => event instanceof TouchedChangeEvent), map((event) => event.touched), ), { initialValue: control.touched }, ) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-untouched.md --- # useControlUntouched Returns whether an AbstractControl is untouched (has not been interacted with) as a signal. The signal updates reactively whenever the control's untouched state changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlUntouched } from 'ng-reactive-utils'; @Component({ template: ` @if (isUntouched()) { Click to enter your email } `, }) class EmailFieldComponent { emailControl = new FormControl(''); isUntouched = useControlUntouched(this.emailControl); } ``` ## Advanced Usage ```typescript import { useControlUntouched } from 'ng-reactive-utils'; @Component({ template: `
@if (isUntouched()) {
Password must be at least 8 characters
} @else if (passwordControl.invalid) {
Please enter a valid password
}
`, }) class PasswordFieldComponent { passwordControl = new FormControl('', [Validators.minLength(8)]); isUntouched = useControlUntouched(this.passwordControl); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | ---------------------------------------- | | `control` | `AbstractControl` | *required* | The control to check untouched state for | ## Returns `Signal` - A readonly signal containing the untouched state (true if not interacted with) ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `control.events` (Angular's unified event stream) filtered to `TouchedChangeEvent` — not `statusChanges`, which does not emit on touched-state changes * Returns `true` when the control has not been blurred or programmatically touched * Returns `false` once `markAsTouched()` is called or the control loses focus * Opposite of `useControlTouched` * Useful for showing hints before user interaction ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl, TouchedChangeEvent } from '@angular/forms'; import { filter, map } from 'rxjs'; /** * Returns whether an AbstractControl is untouched (has not been interacted with) as a signal. * The signal updates reactively whenever the control's untouched state changes, * including when markAsTouched() or markAsUntouched() are called directly. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to check untouched state for * @returns A signal containing the untouched state (true if not interacted with) * * @example * ```typescript * @Component({ * template: ` * * @if (isUntouched()) { * Please fill out this field * } * ` * }) * class MyComponent { * emailControl = new FormControl(''); * isUntouched = useControlUntouched(this.emailControl); * } * ``` */ export const useControlUntouched = (control: AbstractControl): Signal => { return toSignal( control.events.pipe( filter((event): event is TouchedChangeEvent => event instanceof TouchedChangeEvent), map((event) => !event.touched), ), { initialValue: control.untouched }, ) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-valid.md --- # useControlValid Returns whether an AbstractControl is valid as a signal. The signal updates reactively whenever the control's validity changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlValid } from 'ng-reactive-utils'; @Component({ template: ` @if (!isValid()) { Email is invalid } `, }) class EmailStepComponent { emailControl = new FormControl('', [Validators.required, Validators.email]); isValid = useControlValid(this.emailControl); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | --------------------------------- | | `control` | `AbstractControl` | *required* | The control to check validity for | ## Returns `Signal` - A readonly signal containing the validity state (true if valid) ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.statusChanges` observable * Returns `true` when the control passes all validation * Returns `false` when the control has validation errors ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether an AbstractControl is valid as a signal. * The signal updates reactively whenever the control's validity changes. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to check validity for * @returns A signal containing the validity state (true if valid) * * @example * ```typescript * @Component({ * template: ` * * @if (!isValid()) { * Email is invalid * } * ` * }) * class MyComponent { * emailControl = new FormControl('', Validators.email); * isValid = useControlValid(this.emailControl); * } * ``` */ export const useControlValid = (control: AbstractControl): Signal => { return toSignal(control.statusChanges.pipe(map(() => control.valid)), { initialValue: control.valid, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/control/use-control-value.md --- # useControlValue Returns the current value of an AbstractControl as a signal. The signal updates reactively whenever the control value changes. Works with FormControl, FormGroup, and FormArray. ## Usage ```typescript import { useControlValue } from 'ng-reactive-utils'; @Component({ template: `

Hello, {{ name() }}!

`, }) class GreetingComponent { nameControl = new FormControl(''); name = useControlValue(this.nameControl); // Use in computed signals greeting = computed(() => (this.name() ? `Welcome, ${this.name()}!` : 'Please enter your name')); } ``` ## Advanced Usage ```typescript import { useControlValue } from 'ng-reactive-utils'; @Component({ template: ` `, }) class ProductFilterComponent { categoryControl = new FormControl(null); category = useControlValue(this.categoryControl); // Use with resource for reactive data fetching products = resource({ params: () => ({ category: this.category() }), loader: ({ params }) => this.productService.getByCategory(params.category), }); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------------- | ---------- | --------------------------------- | | `control` | `AbstractControl` | *required* | The control to get the value from | ## Returns `Signal` - A readonly signal containing the current control value ## Notes * Works with FormControl, FormGroup, and FormArray * Uses `toSignal` with `control.valueChanges` observable * Type parameter `T` should match your control's value type * Updates reactively on every value change (setValue, patchValue, reset) ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { AbstractControl } from '@angular/forms'; /** * Returns the current value of an AbstractControl as a signal. * The signal updates reactively whenever the control value changes. * Works with FormControl, FormGroup, and FormArray. * * @param control - The AbstractControl to get the value from * @returns A signal containing the current control value * * @example * ```typescript * @Component({ * template: ` * *

Hello, {{ name() }}!

* ` * }) * class MyComponent { * nameControl = new FormControl(''); * name = useControlValue(this.nameControl); * } * ``` */ export const useControlValue = (control: AbstractControl): Signal => { return toSignal(control.valueChanges, { initialValue: control.value, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/browser/use-document-visibility.md --- # useDocumentVisibility Creates a signal that tracks whether the document/tab is visible or hidden. The signal updates when the user switches tabs or minimizes the window. **MDN Reference:** [Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) ## When to Use Use `useDocumentVisibility` when you want to pause or resume activity based on tab focus — for example, pausing animations, stopping polling, or pausing video playback when the user switches away. ## Usage ```typescript import { useDocumentVisibility } from 'ng-reactive-utils'; @Component({ template: `

Tab currently visible: {{ isVisible() }}

`, }) class ExampleComponent { isVisible = useDocumentVisibility(); } ``` ### Pause Polling When Hidden ```typescript import { useDocumentVisibility } from 'ng-reactive-utils'; @Component({ template: `...` }) class DashboardComponent { isVisible = useDocumentVisibility(); constructor() { effect(() => { if (this.isVisible()) { this.startPolling(); } else { this.stopPolling(); } }); } } ``` ## Parameters This composable takes no parameters. ## Returns `Signal` — A readonly signal that is `true` when the page is visible, `false` when hidden (tab is backgrounded or window is minimized). Defaults to `true` on the server (SSR). ## Notes * Returned signal is **readonly** to prevent direct manipulation * Uses `createSharedComposable` internally so there is only one shared instance at a time; event listeners are torn down automatically when no more consumers exist * **SSR safe**: defaults to `true` on the server where `document` is not available ## Source ```ts import { signal, inject, PLATFORM_ID } from '@angular/core'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { createSharedComposable } from '../../../utils/create-shared-composable/create-shared-composable'; /* * Creates a signal that tracks whether the document/tab is visible or hidden. * The signal updates when the user switches tabs or minimizes the window. * * On the server, returns `true` (visible) by default and updates to actual value once hydrated on the client. * * Example: * * const isVisible = useDocumentVisibility(); * * // Use in template * @if (isVisible()) { *
Tab is visible
* } @else { *
Tab is hidden
* } */ export const useDocumentVisibility = createSharedComposable(() => { const document = inject(DOCUMENT); const platformId = inject(PLATFORM_ID); const isBrowser = isPlatformBrowser(platformId); // On server, default to visible (true). On client, use actual document.hidden state const getInitialVisibility = () => (isBrowser ? !document.hidden : true); const visibilitySignal = signal(getInitialVisibility()); const handleVisibilityChange = () => visibilitySignal.set(!document.hidden); // Only set up event listeners in the browser. // visibilitychange fires on document (not window) per the Page Visibility API spec. if (isBrowser) { document.addEventListener('visibilitychange', handleVisibilityChange); } return { value: visibilitySignal.asReadonly(), cleanup: () => { if (isBrowser) { document.removeEventListener('visibilitychange', handleVisibilityChange); } }, }; }); ``` --- --- url: /ng-reactive-utils/composables/browser/use-element-bounding.md --- # useElementBounding Creates a signal that tracks an element's bounding box (position and dimensions). The signal automatically updates when the element is resized or when the page is scrolled/resized. **MDN References:** * [Element.getBoundingClientRect()](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) ## Usage ### Basic Usage ```typescript import { useElementBounding } from 'ng-reactive-utils'; import { Component, viewChild, ElementRef } from '@angular/core'; @Component({ template: `
Drag me around

Position: {{ bounding().x }}, {{ bounding().y }}

Size: {{ bounding().width }} × {{ bounding().height }}

Top: {{ bounding().top }}, Left: {{ bounding().left }}

`, }) class BoxTrackerComponent { boxRef = viewChild('box'); bounding = useElementBounding(this.boxRef); } ``` ### With Custom Configuration ```typescript @Component({ template: `
Content
`, }) class CustomConfigComponent { elementRef = viewChild('element'); // Custom throttle for less frequent updates bounding = useElementBounding(this.elementRef, { throttleMs: 200, windowResize: true, windowScroll: true, }); } ``` ### Manual Updates Only ```typescript @Component({ template: `
Content
`, }) class ManualUpdateComponent { elementRef = viewChild('element'); // Disable automatic updates bounding = useElementBounding(this.elementRef, { windowResize: false, windowScroll: false, }); } ``` ### With Element Signal ```typescript @Component({ template: `
Element 1
Element 2

Width: {{ bounding().width }}

`, }) class SwitchableElementComponent { div1Ref = viewChild('div1'); div2Ref = viewChild('div2'); currentElement = signal(null); bounding = useElementBounding(this.currentElement); ngAfterViewInit() { this.currentElement.set(this.div1Ref()!); } switchElement() { const current = this.currentElement(); const newElement = current === this.div1Ref() ? this.div2Ref() : this.div1Ref(); this.currentElement.set(newElement!); } } ``` ### Sticky Header Detection ```typescript @Component({ template: `
Sticky Header
`, }) class StickyHeaderComponent { headerRef = viewChild('header'); bounding = useElementBounding(this.headerRef); isStuck = computed(() => this.bounding().top <= 0); } ``` ### Intersection Detection ```typescript @Component({ template: `
Box 1
Box 2

Boxes overlapping: {{ areOverlapping() }}

`, }) class OverlapDetectionComponent { box1Ref = viewChild('box1'); box2Ref = viewChild('box2'); box1Bounding = useElementBounding(this.box1Ref); box2Bounding = useElementBounding(this.box2Ref); areOverlapping = computed(() => { const b1 = this.box1Bounding(); const b2 = this.box2Bounding(); return !(b1.right < b2.left || b1.left > b2.right || b1.bottom < b2.top || b1.top > b2.bottom); }); } ``` ### Viewport Visibility Detection ```typescript @Component({ template: `
Content

Visible: {{ isInViewport() }}

`, }) class ViewportVisibilityComponent { elementRef = viewChild('element'); bounding = useElementBounding(this.elementRef); isInViewport = computed(() => { const { top, bottom, left, right } = this.bounding(); return top >= 0 && left >= 0 && bottom <= window.innerHeight && right <= window.innerWidth; }); } ``` ## Parameters | Parameter | Type | Description | | --------------- | ---------------------------------------------------- | ---------------------------------------------------- | | `elementSignal` | `Signal` | Signal containing the element or ElementRef to track | | `config` | `object` | Optional configuration object | ### Configuration Object | Property | Type | Default | Description | | -------------- | --------- | ------- | -------------------------------------------- | | `throttleMs` | `number` | `100` | Throttle delay for scroll/resize events (ms) | | `windowResize` | `boolean` | `true` | Whether to update on window resize | | `windowScroll` | `boolean` | `true` | Whether to update on window scroll | ## Returns `Signal` - A readonly signal containing the element's bounding information ### ElementBounding Type ```typescript type ElementBounding = { x: number; // X position relative to viewport (same as left) y: number; // Y position relative to viewport (same as top) top: number; // Distance from top of viewport right: number; // Distance from right of viewport bottom: number; // Distance from bottom of viewport left: number; // Distance from left of viewport width: number; // Width of the element height: number; // Height of the element update: () => void; // Force an immediate update }; ``` ## Notes * **Returned signal is readonly** to prevent direct manipulation * Uses **ResizeObserver** to efficiently track element size changes * Listens to **window scroll** and **resize** events for position updates (configurable) * All measurements are in **pixels** relative to the viewport * `x` is equivalent to `left`, and `y` is equivalent to `top` * Throttles updates by default (100ms) to prevent excessive recalculations * Event listeners and observers are **automatically cleaned up** on component destruction * On the server, returns default values (all zeros) and updates to actual values once hydrated on the client * Each component instance gets its **own tracking** (not shared like `useMousePosition`) * The `update()` method can be used to **force an immediate update** of the bounding box * Disabling `windowResize` and `windowScroll` will only track size changes via ResizeObserver ## Common Use Cases * **Position tracking**: Track element position for tooltips, popovers, or custom dropdowns * **Scroll animations**: Trigger animations based on element position in viewport * **Sticky elements**: Detect when elements become stuck/unsticky * **Collision detection**: Check if elements overlap or intersect * **Lazy loading**: Load content when elements enter viewport * **Responsive layouts**: Adjust layout based on element dimensions * **Drag and drop**: Track element bounds during drag operations ## Source ````ts import { signal, inject, PLATFORM_ID, effect, DestroyRef, Signal, ElementRef } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; import throttle from 'lodash-es/throttle'; export type ElementBounding = { /** X position relative to the viewport (same as left) */ x: number; /** Y position relative to the viewport (same as top) */ y: number; /** Distance from the top of the viewport */ top: number; /** Distance from the right of the viewport */ right: number; /** Distance from the bottom of the viewport */ bottom: number; /** Distance from the left of the viewport */ left: number; /** Width of the element */ width: number; /** Height of the element */ height: number; /** Forces an update of the bounding box */ update: () => void; }; const defaultBounding: Omit = { x: 0, y: 0, top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0, }; /** * Creates a signal that tracks an element's bounding box (position and dimensions). * The signal updates when the element is resized or when the page is scrolled/resized. * * @param elementSignal - A signal containing the element or ElementRef to track, or null * @param config - Optional configuration * @param config.throttleMs - Throttle delay for scroll/resize events (default: 100ms) * @param config.windowResize - Whether to update on window resize (default: true) * @param config.windowScroll - Whether to update on window scroll (default: true) * * @example * ```ts * // Track a div element * class MyComponent { * divRef = viewChild>('myDiv'); * bounding = useElementBounding(this.divRef); * * logPosition() { * const { x, y, width, height } = this.bounding(); * console.log(`Position: (${x}, ${y}), Size: ${width}x${height}`); * } * } * ``` * * @example * ```ts * // With custom throttle * class MyComponent { * elementRef = viewChild('element'); * bounding = useElementBounding(this.elementRef, { throttleMs: 200 }); * } * ``` * * @example * ```ts * // Manual updates only (no scroll/resize listeners) * class MyComponent { * elementRef = viewChild('element'); * bounding = useElementBounding(this.elementRef, { * windowResize: false, * windowScroll: false, * }); * * manualUpdate() { * this.bounding().update(); * } * } * ``` */ export function useElementBounding( elementSignal: Signal, config: { throttleMs?: number; windowResize?: boolean; windowScroll?: boolean; } = {}, ): Signal { const { throttleMs = 100, windowResize = true, windowScroll = true } = config; const platformId = inject(PLATFORM_ID); const destroyRef = inject(DestroyRef); const isBrowser = isPlatformBrowser(platformId); const boundingSignal = signal({ ...defaultBounding, update: () => {}, }); let resizeObserver: ResizeObserver | null = null; let windowListenersActive = false; const updateBounding = () => { const elementOrRef = elementSignal(); const element = elementOrRef instanceof ElementRef ? elementOrRef.nativeElement : elementOrRef; if (!element || !isBrowser) { boundingSignal.update((prev) => ({ ...defaultBounding, update: prev.update })); return; } const rect = element.getBoundingClientRect(); boundingSignal.update((prev) => ({ x: rect.x, y: rect.y, top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, width: rect.width, height: rect.height, update: prev.update, })); }; const throttledUpdate = throttle(updateBounding, throttleMs); // Set the update function boundingSignal.update((prev) => ({ ...prev, update: updateBounding, })); if (isBrowser) { // Watch for element changes effect(() => { const elementOrRef = elementSignal(); const element = elementOrRef instanceof ElementRef ? elementOrRef.nativeElement : elementOrRef; // Clean up previous observer if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } // Clean up window event listeners if (windowListenersActive) { if (windowResize) { window.removeEventListener('resize', throttledUpdate); } if (windowScroll) { window.removeEventListener('scroll', throttledUpdate, true); } windowListenersActive = false; } if (element) { // Initial update updateBounding(); // Set up ResizeObserver for size changes resizeObserver = new ResizeObserver(throttledUpdate); resizeObserver.observe(element); // Set up window event listeners if enabled if (windowResize) { window.addEventListener('resize', throttledUpdate); } if (windowScroll) { window.addEventListener('scroll', throttledUpdate, true); // Use capture to catch all scroll events } windowListenersActive = true; } else { boundingSignal.update((prev) => ({ ...defaultBounding, update: prev.update })); } }); } // Cleanup destroyRef.onDestroy(() => { throttledUpdate.cancel(); if (resizeObserver) { resizeObserver.disconnect(); } if (isBrowser && windowListenersActive) { if (windowResize) { window.removeEventListener('resize', throttledUpdate); } if (windowScroll) { window.removeEventListener('scroll', throttledUpdate, true); } } }); return boundingSignal.asReadonly(); } ```` --- --- url: /ng-reactive-utils/composables/browser/use-event-listener.md --- # useEventListener Attaches an event listener to a target with automatic cleanup when the component is destroyed. **MDN Reference:** [EventTarget.addEventListener()](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) ## When to Use Use `useEventListener` for **global events** (`window`, `document`) and for **building reusable composables** that encapsulate event logic. For elements inside your own template, prefer Angular's built-in event binding — it's cleaner and automatically cleaned up: ```html
Hover me
``` ## Usage ### Building a Reusable Composable The primary use case is encapsulating event logic into a composable that can be shared across components: ```typescript import { useEventListener } from 'ng-reactive-utils'; function useIsHovered(element: Signal) { const isHovering = signal(false); useEventListener('mouseenter', () => isHovering.set(true), { target: element }); useEventListener('mouseleave', () => isHovering.set(false), { target: element }); return isHovering.asReadonly(); } @Component({ template: `
Hover over me

Hovering: {{ isHovering() }}

`, }) class HoverExampleComponent { boxRef = viewChild('box'); isHovering = useIsHovered(this.boxRef); } ``` ### Before Unload Warning ```typescript @Component({ template: `
Unsaved changes...
`, }) class UnsavedChangesComponent { hasUnsavedChanges = signal(true); // The returned function removes the listener early if needed removeListener = useEventListener('beforeunload', (event) => { if (this.hasUnsavedChanges()) { event.preventDefault(); event.returnValue = ''; } }); } ``` ## Parameters | Parameter | Type | Description | | --------- | ------------------------------------ | ------------------------------------- | | `event` | `string` | Event name (e.g. `'click'`, `'keydown'`) | | `handler` | `(event: Event) => void` | Event handler function | | `options` | `UseEventListenerOptions` (optional) | Configuration options | ### Options | Property | Type | Default | Description | | --------- | -------------------------------------------------------------------------- | -------- | ------------------------------------------------------ | | `target` | `Window \| Document \| Signal` | `window` | Event target | | `capture` | `boolean` | `false` | Use capture phase | | `passive` | `boolean` | `false` | Mark listener as passive (improves scroll performance) | | `once` | `boolean` | `false` | Remove listener after first invocation | ## Returns `() => void` — A cleanup function that removes the listener immediately. Safe to call multiple times. Automatic cleanup on component destroy still runs safely if called early. ## Notes * **SSR safe**: No-ops on the server; listeners are only attached in the browser. * **Signal targets**: When `target` is a signal (e.g. from `viewChild()`), the listener re-registers whenever the signal value changes. `undefined` values are handled gracefully — the listener waits until a valid element is available. * **Passive listeners**: Use `passive: true` for scroll, wheel, and touch events to improve scroll performance. ## Source ````ts import { inject, PLATFORM_ID, DestroyRef, effect, Signal, ElementRef, isSignal, } from '@angular/core'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; export type UseEventListenerTarget = | Window | Document | Element | ElementRef | Signal | null | undefined; export interface UseEventListenerOptions extends AddEventListenerOptions { target?: UseEventListenerTarget; } /** * Attaches an event listener to a target (window, document, or element) with automatic cleanup. * The listener is automatically removed when the component is destroyed. * * @param event - Event name (e.g., 'click', 'keydown') * @param handler - Event handler function * @param options - Configuration options * * @remarks * **IMPORTANT**: When using signal targets from `viewChild()` or `viewChildren()`, be aware that * these signals are `undefined` during constructor execution. The effect will handle this gracefully * by not attaching listeners until the signal has a valid value. However, for immediate element * access, consider using this in `afterNextRender()` or other lifecycle hooks. * * **Signal targets**: When a signal target changes, the listener is automatically removed from the * old element and attached to the new element. The implementation checks element identity to avoid * unnecessary re-registration when the signal emits the same element reference. * * @example * ```ts * // Listen to window events - works in constructor * constructor() { * useEventListener('keydown', (event) => { * console.log('Key pressed:', event.key); * }); * } * ``` * * @example * ```ts * // Listen to document events - works in constructor * constructor() { * const document = inject(DOCUMENT); * useEventListener('click', (event) => { * console.log('Clicked at:', event.clientX, event.clientY); * }, { target: document }); * } * ``` * * @example * ```ts * // Listen to element events with viewChild signal * // Signal is undefined in constructor but effect handles it gracefully * boxRef = viewChild('box'); * * constructor() { * useEventListener('mouseenter', () => { * console.log('Mouse entered box'); * }, { target: this.boxRef }); * } * ``` * * @example * ```ts * // With passive option for better scroll performance * constructor() { * useEventListener('scroll', (event) => { * console.log('Scrolled'); * }, { passive: true }); * } * ``` * * @example * ```ts * // Manual cleanup before component destroy * constructor() { * const cleanup = useEventListener('mousemove', (event) => { * console.log('Mouse moved:', event.clientX, event.clientY); * }); * * // Later, remove the listener early * cleanup(); * } * ``` * * @returns A cleanup function that removes the event listener. Safe to call multiple times. */ export function useEventListener( event: K, handler: (event: WindowEventMap[K]) => void, options?: UseEventListenerOptions, ): () => void; export function useEventListener( event: K, handler: (event: DocumentEventMap[K]) => void, options?: UseEventListenerOptions, ): () => void; export function useEventListener( event: K, handler: (event: HTMLElementEventMap[K]) => void, options?: UseEventListenerOptions, ): () => void; export function useEventListener( event: string, handler: (event: Event) => void, options?: UseEventListenerOptions, ): () => void; export function useEventListener( event: string, handler: (event: Event) => void, options: UseEventListenerOptions = {}, ): () => void { const document = inject(DOCUMENT); const platformId = inject(PLATFORM_ID); const destroyRef = inject(DestroyRef); const isBrowser = isPlatformBrowser(platformId); const noop = () => {}; if (!isBrowser) { return noop; // No-op on server } const { target: targetOption, ...listenerOptions } = options; // Determine if target is a signal const targetIsSignal = isSignal(targetOption); // Track whether cleanup has already been called to make it safe to call multiple times let isCleanedUp = false; if (targetIsSignal) { // Handle signal target with effect let currentListenerCleanup: (() => void) | null = null; let previousElement: EventTarget | null = null; const cleanupEffect = effect(() => { const signalValue = (targetOption as Signal)(); // For signals, don't default to window - wait for a valid value const element = resolveTarget(signalValue, document, false); // Only update listener if element reference actually changed // This prevents unnecessary re-registration when signal emits same element if (element !== previousElement) { // Clean up previous listener if (currentListenerCleanup) { currentListenerCleanup(); currentListenerCleanup = null; } // Add new listener if element exists and not already cleaned up if (element && !isCleanedUp) { element.addEventListener(event, handler, listenerOptions); currentListenerCleanup = () => { element.removeEventListener(event, handler, listenerOptions); }; } previousElement = element; } }); const cleanup = () => { if (isCleanedUp) return; isCleanedUp = true; cleanupEffect.destroy(); if (currentListenerCleanup) { currentListenerCleanup(); currentListenerCleanup = null; } }; // Cleanup on destroy: first destroy effect, then remove any remaining listener destroyRef.onDestroy(cleanup); return cleanup; } else { // Handle non-signal target // For non-signals, default to window if target is null/undefined const target = resolveTarget(targetOption, document, true); if (!target) { return noop; } target.addEventListener(event, handler, listenerOptions); const cleanup = () => { if (isCleanedUp) return; isCleanedUp = true; target.removeEventListener(event, handler, listenerOptions); }; // Cleanup on destroy destroyRef.onDestroy(cleanup); return cleanup; } } /** * Resolves the target to an event target (Window, Document, or Element) * @param target - The target to resolve * @param document - The document instance * @param defaultToWindow - Whether to default to window when target is null/undefined */ function resolveTarget( target: Window | Document | Element | ElementRef | null | undefined, document: Document, defaultToWindow: boolean, ): EventTarget | null { if (!target) { // For non-signal targets, default to window // For signal targets, return null to wait for a valid value return defaultToWindow ? document.defaultView : null; } if (target instanceof ElementRef) { return target.nativeElement; } return target as EventTarget; } ```` --- --- url: /ng-reactive-utils/composables/form/use-form-dirty.md --- # useFormDirty Returns whether a FormGroup is dirty (has been modified) as a signal. The signal updates reactively whenever the form's dirty state changes. ## Usage ```typescript import { useFormDirty } from 'ng-reactive-utils'; @Component({ template: `
@if (isDirty()) {
You have unsaved changes
}
`, }) class EditorComponent { form = new FormGroup({ title: new FormControl(''), content: new FormControl(''), }); isDirty = useFormDirty(this.form); resetForm() { this.form.reset(); } // Use with canDeactivate guard canDeactivate(): boolean { return !this.isDirty() || confirm('Discard unsaved changes?'); } } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | -------------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to check dirty state for | ## Returns `Signal` - A readonly signal containing the dirty state (true if modified) ## Notes * Uses `toSignal` with `form.valueChanges` observable * Returns `true` when any control value has been changed by the user * Returns `false` when form is pristine (unchanged) * Useful for "unsaved changes" warnings and save button states ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether a FormGroup is dirty (has been modified) as a signal. * The signal updates reactively whenever the form's dirty state changes. * * @param form - The FormGroup to check dirty state for * @returns A signal containing the dirty state (true if modified) * * @example * ```typescript * @Component({ * template: ` *
* * @if (isDirty()) { * You have unsaved changes * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * name: new FormControl('') * }); * isDirty = useFormDirty(this.form); * } * ``` */ export const useFormDirty = (form: FormGroup): Signal => { return toSignal(form.valueChanges.pipe(map(() => form.dirty)), { initialValue: form.dirty, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-disabled.md --- # useFormDisabled Returns whether a FormGroup is disabled as a signal. The signal updates reactively whenever the form's disabled state changes. ## Usage ```typescript import { useFormDisabled } from 'ng-reactive-utils'; @Component({ template: `
@if (isDisabled()) {

Form is currently disabled

}
`, }) class EditableFormComponent { form = new FormGroup({ email: new FormControl(''), }); isDisabled = useFormDisabled(this.form); toggleForm() { if (this.form.disabled) { this.form.enable(); } else { this.form.disable(); } } } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ----------------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to check disabled state for | ## Returns `Signal` - A readonly signal containing the disabled state (true if disabled) ## Notes * Uses `toSignal` with `form.statusChanges` observable * Returns `true` when the form is disabled via `form.disable()` * Returns `false` when the form is enabled * A disabled form excludes its value from the parent form value ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether a FormGroup is disabled as a signal. * The signal updates reactively whenever the form's disabled state changes. * * @param form - The FormGroup to check disabled state for * @returns A signal containing the disabled state (true if disabled) * * @example * ```typescript * @Component({ * template: ` *
* * @if (isDisabled()) { * Form is currently disabled * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * email: new FormControl('') * }); * isDisabled = useFormDisabled(this.form); * * disableForm() { * this.form.disable(); * } * } * ``` */ export const useFormDisabled = (form: FormGroup): Signal => { return toSignal(form.statusChanges.pipe(map(() => form.disabled)), { initialValue: form.disabled, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-errors.md --- # useFormErrors Returns the validation errors of a FormGroup as a signal. The signal updates reactively whenever the form's validation errors change. ## Usage ```typescript import { useFormErrors } from 'ng-reactive-utils'; @Component({ template: `
@if (formErrors()?.['passwordMismatch']) { Passwords do not match } @if (formErrors()?.['weakPassword']) { Password is too weak }
`, }) class PasswordFormComponent { form = new FormGroup( { password: new FormControl(''), confirmPassword: new FormControl(''), }, { validators: [this.passwordMatchValidator, this.passwordStrengthValidator] }, ); formErrors = useFormErrors(this.form); passwordMatchValidator(group: FormGroup): ValidationErrors | null { const password = group.get('password')?.value; const confirm = group.get('confirmPassword')?.value; return password === confirm ? null : { passwordMismatch: true }; } passwordStrengthValidator(group: FormGroup): ValidationErrors | null { const password = group.get('password')?.value; return password.length >= 8 ? null : { weakPassword: true }; } } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | -------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to get errors from | ## Returns `Signal` - A readonly signal containing the validation errors or null if no errors ## Notes * Uses `toSignal` with `form.statusChanges` observable * Returns form-level validation errors only (not individual control errors) * Returns `null` when the form has no validation errors * Useful for cross-field validation like password confirmation ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup, ValidationErrors } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns the validation errors of a FormGroup as a signal. * The signal updates reactively whenever the form's validation errors change. * * @param form - The FormGroup to get errors from * @returns A signal containing the validation errors or null if no errors * * @example * ```typescript * @Component({ * template: ` *
* * * @if (formErrors()?.['passwordMismatch']) { * Passwords do not match * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * password: new FormControl(''), * confirmPassword: new FormControl('') * }, { validators: passwordMatchValidator }); * formErrors = useFormErrors(this.form); * } * ``` */ export const useFormErrors = (form: FormGroup): Signal => { return toSignal(form.statusChanges.pipe(map(() => form.errors)), { initialValue: form.errors, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-pending.md --- # useFormPending Returns whether a FormGroup has pending async validators as a signal. The signal updates reactively whenever the form's pending state changes. ## Usage ```typescript import { useFormPending } from 'ng-reactive-utils'; @Component({ template: `
@if (isPending()) { Checking availability... }
`, }) class RegistrationComponent { form = new FormGroup({ username: new FormControl('', [], [this.usernameValidator()]), }); isPending = useFormPending(this.form); usernameValidator(): AsyncValidatorFn { return (control) => { return this.userService .checkUsername(control.value) .pipe(map((exists) => (exists ? { usernameTaken: true } : null))); }; } } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ---------------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to check pending state for | ## Returns `Signal` - A readonly signal containing the pending state (true if async validators are running) ## Notes * Uses `toSignal` with `form.statusChanges` observable * Returns `true` when any control has running async validators * Returns `false` when all async validators have completed * Useful for showing loading indicators during async validation ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether a FormGroup has pending async validators as a signal. * The signal updates reactively whenever the form's pending state changes. * * @param form - The FormGroup to check pending state for * @returns A signal containing the pending state (true if async validators are running) * * @example * ```typescript * @Component({ * template: ` *
* * @if (isPending()) { * Checking availability... * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * username: new FormControl('', [], [asyncUsernameValidator]) * }); * isPending = useFormPending(this.form); * } * ``` */ export const useFormPending = (form: FormGroup): Signal => { return toSignal(form.statusChanges.pipe(map(() => form.pending)), { initialValue: form.pending, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-pristine.md --- # useFormPristine Returns whether a FormGroup is pristine (has not been modified) as a signal. The signal updates reactively whenever the form's pristine state changes. ## Usage ```typescript import { useFormPristine } from 'ng-reactive-utils'; @Component({ template: `
@if (isPristine()) {

Start typing to make changes

} @else {

Form has been modified

}
`, }) class SimpleFormComponent { form = new FormGroup({ name: new FormControl(''), }); isPristine = useFormPristine(this.form); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ----------------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to check pristine state for | ## Returns `Signal` - A readonly signal containing the pristine state (true if not modified) ## Notes * Uses `toSignal` with `form.valueChanges` observable * Returns `true` when no control has been modified * Returns `false` when any control value has been changed * Opposite of `useFormDirty` ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether a FormGroup is pristine (has not been modified) as a signal. * The signal updates reactively whenever the form's pristine state changes. * * @param form - The FormGroup to check pristine state for * @returns A signal containing the pristine state (true if not modified) * * @example * ```typescript * @Component({ * template: ` *
* * @if (isPristine()) { * Form has not been modified * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * name: new FormControl('') * }); * isPristine = useFormPristine(this.form); * } * ``` */ export const useFormPristine = (form: FormGroup): Signal => { return toSignal(form.valueChanges.pipe(map(() => form.pristine)), { initialValue: form.pristine, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-state.md --- # useFormState Converts a FormGroup into a reactive state object with signals for all form properties. This provides a comprehensive view of the form's state that updates reactively. ## Usage ```typescript import { useFormState } from 'ng-reactive-utils'; @Component({ template: `
@if (formState.invalid()) { Form has errors } @if (formState.dirty()) { You have unsaved changes }
{{ formState.value() | json }}
`, }) class UserFormComponent { form = new FormGroup({ name: new FormControl('', Validators.required), email: new FormControl('', [Validators.required, Validators.email]), }); formState = useFormState<{ name: string; email: string }>(this.form); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ----------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to convert to signals | ## Returns `FormState` - An object containing signals for all form state properties: | Property | Type | Description | | ----------- | --------------------------- | ----------------------------------------------------------------- | --------------------------------- | | `value` | `Signal` | The current value of the form | | `status` | `Signal` | The validation status ('VALID', 'INVALID', 'PENDING', 'DISABLED') | | `valid` | `Signal` | Whether the form is valid | | `invalid` | `Signal` | Whether the form is invalid | | `pending` | `Signal` | Whether async validators are running | | `disabled` | `Signal` | Whether the form is disabled | | `enabled` | `Signal` | Whether the form is enabled | | `dirty` | `Signal` | Whether the form has been modified | | `pristine` | `Signal` | Whether the form has not been modified | | `touched` | `Signal` | Whether the form has been interacted with | | `untouched` | `Signal` | Whether the form has not been interacted with | | `errors` | `Signal` | The validation errors of the form | ## Notes * Composes all individual form composables (`useFormValue`, `useFormValid`, `useFormTouched`, etc.) into a single convenience object * `invalid` is `computed(() => !valid())` and `enabled` is `computed(() => !disabled())` — they are derived signals, not independent subscriptions, so they are always atomically consistent with their counterparts * `touched` and `untouched` use merged child control events (see [`useFormTouched`](./use-form-touched) for details on the shallow-only limitation) * Type parameter `T` must extend `object` and should match your form's value structure * For individual properties, consider using the specific composables like `useFormValue`, `useFormValid`, etc. to avoid subscribing to all observables when only one is needed ## Source ````ts import { computed, Signal } from '@angular/core'; import { FormGroup, FormControlStatus, ValidationErrors } from '@angular/forms'; import { useFormValue } from '../use-form-value/use-form-value.composable'; import { useFormStatus } from '../use-form-status/use-form-status.composable'; import { useFormValid } from '../use-form-valid/use-form-valid.composable'; import { useFormPending } from '../use-form-pending/use-form-pending.composable'; import { useFormDisabled } from '../use-form-disabled/use-form-disabled.composable'; import { useFormDirty } from '../use-form-dirty/use-form-dirty.composable'; import { useFormPristine } from '../use-form-pristine/use-form-pristine.composable'; import { useFormTouched } from '../use-form-touched/use-form-touched.composable'; import { useFormUntouched } from '../use-form-untouched/use-form-untouched.composable'; import { useFormErrors } from '../use-form-errors/use-form-errors.composable'; /** * Represents the reactive state of a FormGroup as signals. */ export interface FormState { /** The current value of the form */ value: Signal; /** The validation status of the form */ status: Signal; /** Whether the form is valid */ valid: Signal; /** Whether the form is invalid */ invalid: Signal; /** Whether the form has pending async validators */ pending: Signal; /** Whether the form is disabled */ disabled: Signal; /** Whether the form is enabled */ enabled: Signal; /** Whether the form value has been modified */ dirty: Signal; /** Whether the form value has not been modified */ pristine: Signal; /** Whether the form has been interacted with */ touched: Signal; /** Whether the form has not been interacted with */ untouched: Signal; /** The validation errors of the form */ errors: Signal; } /** * Converts a FormGroup into a reactive state object with signals for all form properties. * This provides a comprehensive view of the form's state that updates reactively. * * @param form - The FormGroup to convert to signals * @returns An object containing signals for all form state properties * * @example * ```typescript * @Component({ * template: ` *
* * @if (formState.invalid()) { * Form has errors * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * name: new FormControl('') * }); * formState = useFormState(this.form); * } * ``` */ export const useFormState = (form: FormGroup): FormState => { const valid = useFormValid(form); const disabled = useFormDisabled(form); return { value: useFormValue(form), status: useFormStatus(form), valid, invalid: computed(() => !valid()), pending: useFormPending(form), disabled, enabled: computed(() => !disabled()), dirty: useFormDirty(form), pristine: useFormPristine(form), touched: useFormTouched(form), untouched: useFormUntouched(form), errors: useFormErrors(form), }; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-status.md --- # useFormStatus Returns the validation status of a FormGroup as a signal. The signal updates reactively whenever the form's status changes. ## Usage ```typescript import { useFormStatus } from 'ng-reactive-utils'; @Component({ template: `
Status: {{ formStatus() }}
@switch (formStatus()) { @case ('VALID') { } @case ('INVALID') {

Please fix the errors above

} @case ('PENDING') {

Validating...

} @case ('DISABLED') {

Form is disabled

} }
`, styles: ` .status-valid { color: green; } .status-invalid { color: red; } .status-pending { color: orange; } .status-disabled { color: gray; } `, }) class StatusFormComponent { form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email]), }); formStatus = useFormStatus(this.form); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | -------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to get status from | ## Returns `Signal` - A readonly signal containing the form status ## Status Values | Status | Description | | ---------- | ------------------------------------------------- | | `VALID` | All controls pass validation | | `INVALID` | At least one control has validation errors | | `PENDING` | At least one control has pending async validators | | `DISABLED` | The form is disabled | ## Notes * Uses `toSignal` with `form.statusChanges` observable * Status is determined by the combined status of all controls * Useful for conditional rendering based on form state ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup, FormControlStatus } from '@angular/forms'; /** * Returns the validation status of a FormGroup as a signal. * The signal updates reactively whenever the form's status changes. * * Status values: * - 'VALID': All controls are valid * - 'INVALID': At least one control is invalid * - 'PENDING': At least one control has pending async validators * - 'DISABLED': The form is disabled * * @param form - The FormGroup to get status from * @returns A signal containing the form status * * @example * ```typescript * @Component({ * template: ` *
* * Status: {{ formStatus() }} *
* ` * }) * class MyComponent { * form = new FormGroup({ * email: new FormControl('', Validators.required) * }); * formStatus = useFormStatus(this.form); * } * ``` */ export const useFormStatus = (form: FormGroup): Signal => { return toSignal(form.statusChanges, { initialValue: form.status, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-touched.md --- # useFormTouched Returns whether a FormGroup has been touched (interacted with) as a signal. The signal updates reactively whenever the form's touched state changes. ## Usage ```typescript import { useFormTouched } from 'ng-reactive-utils'; @Component({ template: `
@if (isTouched() && form.get('email')?.invalid) { Please enter a valid email }
`, }) class ContactFormComponent { form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email]), }); isTouched = useFormTouched(this.form); onBlur() { // Form will be marked as touched automatically on blur } } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ---------------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to check touched state for | ## Returns `Signal` - A readonly signal containing the touched state (true if interacted with) ## Notes * Merges `TouchedChangeEvent` events from the `FormGroup` **and all direct child controls** — this is necessary because Angular does not propagate `TouchedChangeEvent` from child controls up to the parent group * Uses `control.events` (not `statusChanges`) to listen for touched-state changes; `statusChanges` does not emit on touch changes * Returns the group-level `form.touched` value on each event, so the signal reflects the overall touched state of the form * Only tracks **direct** child controls — grandchild controls inside nested `FormGroup` or `FormArray` children are not observed; use `useControlTouched` on the nested group directly if needed * Returns `true` when `markAsTouched()` is called on the form or any direct child loses focus ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup, TouchedChangeEvent } from '@angular/forms'; import { filter, map, merge } from 'rxjs'; /** * Returns whether a FormGroup has been touched (interacted with) as a signal. * The signal updates reactively whenever any control in the form is touched or * untouched, including programmatic calls to markAsTouched() / markAsUntouched(). * * @param form - The FormGroup to check touched state for * @returns A signal containing the touched state (true if interacted with) * * @example * ```typescript * @Component({ * template: ` *
* * @if (isTouched() && form.get('email')?.invalid) { * Please enter a valid email * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * email: new FormControl('', Validators.email) * }); * isTouched = useFormTouched(this.form); * } * ``` */ export const useFormTouched = (form: FormGroup): Signal => { // TouchedChangeEvent on a FormGroup only fires when markAsTouched() / markAsUntouched() // is called on the form itself — it does not propagate from child control blur events. // Merging events from all child controls ensures the signal stays accurate when a user // interacts with any field in the form. const allControls = [form, ...Object.values(form.controls)]; const anyTouchedChange$ = merge(...allControls.map((control) => control.events)).pipe( filter((event): event is TouchedChangeEvent => event instanceof TouchedChangeEvent), map(() => form.touched), ); return toSignal(anyTouchedChange$, { initialValue: form.touched }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-untouched.md --- # useFormUntouched Returns whether a FormGroup is untouched (has not been interacted with) as a signal. The signal updates reactively whenever the form's untouched state changes. ## Usage ```typescript import { useFormUntouched } from 'ng-reactive-utils'; @Component({ template: `
@if (isUntouched()) {

Click on the field to start

}
`, }) class GuidedFormComponent { form = new FormGroup({ email: new FormControl(''), }); isUntouched = useFormUntouched(this.form); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ------------------------------------------ | | `form` | `FormGroup` | *required* | The FormGroup to check untouched state for | ## Returns `Signal` - A readonly signal containing the untouched state (true if not interacted with) ## Notes * Merges `TouchedChangeEvent` events from the `FormGroup` **and all direct child controls** — this is necessary because Angular does not propagate `TouchedChangeEvent` from child controls up to the parent group * Uses `control.events` (not `statusChanges`) to listen for touched-state changes; `statusChanges` does not emit on touch changes * Returns the group-level `form.untouched` value on each event, so the signal reflects the overall untouched state of the form * Only tracks **direct** child controls — grandchild controls inside nested `FormGroup` or `FormArray` children are not observed; use `useControlUntouched` on the nested group directly if needed * Returns `false` once any direct child control loses focus or `markAsTouched()` is called * Opposite of `useFormTouched` ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup, TouchedChangeEvent } from '@angular/forms'; import { filter, map, merge } from 'rxjs'; /** * Returns whether a FormGroup is untouched (has not been interacted with) as a signal. * The signal updates reactively whenever any control in the form is touched or * untouched, including programmatic calls to markAsTouched() / markAsUntouched(). * * @param form - The FormGroup to check untouched state for * @returns A signal containing the untouched state (true if not interacted with) * * @example * ```typescript * @Component({ * template: ` *
* * @if (isUntouched()) { * Please fill out the form * } *
* ` * }) * class MyComponent { * form = new FormGroup({ * email: new FormControl('') * }); * isUntouched = useFormUntouched(this.form); * } * ``` */ export const useFormUntouched = (form: FormGroup): Signal => { // TouchedChangeEvent on a FormGroup only fires when markAsTouched() / markAsUntouched() // is called on the form itself — it does not propagate from child control blur events. // Merging events from all child controls ensures the signal stays accurate when a user // interacts with any field in the form. const allControls = [form, ...Object.values(form.controls)]; const anyTouchedChange$ = merge(...allControls.map((control) => control.events)).pipe( filter((event): event is TouchedChangeEvent => event instanceof TouchedChangeEvent), map(() => form.untouched), ); return toSignal(anyTouchedChange$, { initialValue: form.untouched }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-valid.md --- # useFormValid Returns whether a FormGroup is valid as a signal. The signal updates reactively whenever the form's validity changes. ## Usage ```typescript import { useFormValid } from 'ng-reactive-utils'; @Component({ template: `
@if (!isValid()) {

Please fill in all required fields correctly.

}
`, }) class LoginFormComponent { form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email]), password: new FormControl('', [Validators.required, Validators.minLength(8)]), }); isValid = useFormValid(this.form); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ----------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to check validity for | ## Returns `Signal` - A readonly signal containing the validity state (true if valid) ## Notes * Uses `toSignal` with `form.statusChanges` observable * Returns `true` when all controls pass validation * Returns `false` when any control has validation errors * Updates reactively when any control's validity changes ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { map } from 'rxjs'; /** * Returns whether a FormGroup is valid as a signal. * The signal updates reactively whenever the form's validity changes. * * @param form - The FormGroup to check validity for * @returns A signal containing the validity state (true if valid) * * @example * ```typescript * @Component({ * template: ` *
* * *
* ` * }) * class MyComponent { * form = new FormGroup({ * email: new FormControl('', Validators.required) * }); * isValid = useFormValid(this.form); * } * ``` */ export const useFormValid = (form: FormGroup): Signal => { return toSignal(form.statusChanges.pipe(map(() => form.valid)), { initialValue: form.valid, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/form/use-form-value.md --- # useFormValue Returns the current value of a FormGroup as a signal. The signal updates reactively whenever the form value changes. ## Usage ```typescript import { useFormValue } from 'ng-reactive-utils'; @Component({ template: `

Hello, {{ formValue().firstName }} {{ formValue().lastName }}!

{{ formValue() | json }}
`, }) class GreetingComponent { form = new FormGroup({ firstName: new FormControl(''), lastName: new FormControl(''), }); formValue = useFormValue<{ firstName: string; lastName: string }>(this.form); // Use in computed signals fullName = computed(() => `${this.formValue().firstName} ${this.formValue().lastName}`.trim()); } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | ----------- | ---------- | ----------------------------------- | | `form` | `FormGroup` | *required* | The FormGroup to get the value from | ## Returns `Signal` - A readonly signal containing the current form value ## Notes * Uses `toSignal` with `form.valueChanges` observable * Type parameter `T` should match your form's value structure * Updates reactively on every value change (patchValue, setValue, reset) * Can be used in computed signals for derived state ## Source ````ts import { Signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; /** * Returns the current value of a FormGroup as a signal. * The signal updates reactively whenever the form value changes. * * @param form - The FormGroup to get the value from * @returns A signal containing the current form value * * @example * ```typescript * @Component({ * template: ` *
* *
*
{{ formValue() | json }}
* ` * }) * class MyComponent { * form = new FormGroup({ * email: new FormControl('') * }); * formValue = useFormValue<{ email: string }>(this.form); * } * ``` */ export const useFormValue = (form: FormGroup): Signal => { return toSignal(form.valueChanges, { initialValue: form.value, }) as Signal; }; ```` --- --- url: /ng-reactive-utils/composables/browser/use-local-storage.md --- # useLocalStorage Creates a writable signal that automatically syncs with localStorage. The signal persists its value across page reloads and updates when the storage value changes in other tabs/windows. **MDN Reference:** [Window.localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) ## Usage ### Basic Usage ```typescript import { useLocalStorage } from 'ng-reactive-utils'; @Component({ template: `

Counter: {{ counter() }}

`, }) class CounterComponent { // Value persists across page reloads counter = useLocalStorage('counter', 0); } ``` ### With Object Values ```typescript interface UserPreferences { theme: 'light' | 'dark'; fontSize: number; notifications: boolean; } @Component({ template: `

Font size: {{ prefs().fontSize }}px

`, }) class PreferencesComponent { prefs = useLocalStorage('user-prefs', { theme: 'light', fontSize: 16, notifications: true, }); toggleTheme() { this.prefs.update((p) => ({ ...p, theme: p.theme === 'light' ? 'dark' : 'light', })); } toggleNotifications() { this.prefs.update((p) => ({ ...p, notifications: !p.notifications })); } } ``` ### With Custom Serialization ```typescript @Component({ template: `

Last visit: {{ lastVisit() }}

`, }) class LastVisitComponent { lastVisit = useLocalStorage('last-visit', new Date(), { serializer: { read: (value: string) => new Date(value), write: (value: Date) => value.toISOString(), }, }); constructor() { // Update to current time this.lastVisit.set(new Date()); } } ``` ### Array Storage ```typescript interface TodoItem { id: number; text: string; done: boolean; } @Component({ template: `
    @for (todo of todos(); track todo.id) {
  • {{ todo.text }}
  • }
`, }) class TodoListComponent { todos = useLocalStorage('todos', []); addTodo() { const newTodo: TodoItem = { id: Date.now(), text: 'New task', done: false, }; this.todos.update((todos) => [...todos, newTodo]); } toggleTodo(id: number) { this.todos.update((todos) => todos.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo ) ); } } ``` ### Sync Across Tabs ```typescript @Component({ template: `

Shared counter: {{ sharedCounter() }}

Open this page in another tab to see sync in action!

`, }) class CrossTabSyncComponent { // Automatically syncs when changed in another tab sharedCounter = useLocalStorage('shared-counter', 0); } ``` ### Computed from Storage ```typescript @Component({ template: `

Search history: {{ searchHistory().length }} items

`, }) class SearchComponent { searchQuery = useLocalStorage('current-search', ''); searchHistory = useLocalStorage('search-history', []); // Computed signal based on storage hasSearched = computed(() => this.searchHistory().length > 0); constructor() { // Add to history when query changes effect(() => { const query = this.searchQuery(); if (query.trim()) { this.searchHistory.update((history) => [query, ...history.slice(0, 9)]); } }); } } ``` ### Form State Persistence ```typescript interface FormData { name: string; email: string; message: string; } @Component({ template: `
`, }) class DraftFormComponent { draftForm = useLocalStorage('form-draft', { name: '', email: '', message: '', }); updateDraft(field: keyof FormData, value: string) { this.draftForm.update((draft) => ({ ...draft, [field]: value })); } clearDraft() { this.draftForm.set({ name: '', email: '', message: '' }); } } ``` ### Removing Values ```typescript @Component({ template: `

Token: {{ token() || 'Not set' }}

`, }) class LoginComponent { token = useLocalStorage('auth-token', null); login() { this.token.set('abc123'); } logout() { // Set to null to remove from localStorage this.token.set(null); } } ``` ## Parameters | Parameter | Type | Description | | --------------- | ------------------------------- | ----------------------------------------------------- | | `key` | `string` | localStorage key to store the value under | | `defaultValue` | `T` | Default value if no stored value exists | | `options` | `UseStorageOptions` (optional) | Configuration options | ### Options Object | Property | Type | Default | Description | | --------------------------- | ----------------------------------------------------- | -------------- | -------------------------------------------------------------- | | `serializer` | `{ read: (value: string) => T; write: (value: T) => string }` | JSON serializer | Custom serialization logic | | `writeDefaults` | `boolean` | `true` | Write default value to storage on initialization if not present | ## Returns `WritableSignal` - A writable signal that syncs with localStorage ## Notes * **Writable signal**: Returned signal can be updated using `.set()` and `.update()` * **Automatic persistence**: Changes to the signal automatically save to localStorage * **Cross-tab sync**: Changes in other tabs/windows automatically update the signal * **SSR safe**: Returns default value on server, hydrates from localStorage on client * **Type safe**: Full TypeScript support with generic type parameter * **JSON by default**: Uses `JSON.stringify`/`JSON.parse` for serialization by default * **Custom serialization**: Provide custom serializer for complex types (Date, Map, Set, etc.) * **Null removes**: Setting the value to `null` removes the key from localStorage * **Error handling**: Gracefully handles quota exceeded errors and parse errors * **Storage events**: Automatically listens to `storage` events to sync across tabs * **Automatic cleanup**: Storage event listeners are removed when component is destroyed ## Common Serializers ### Date Serializer ```typescript { read: (value: string) => new Date(value), write: (value: Date) => value.toISOString(), } ``` ### Map Serializer ```typescript { read: (value: string) => new Map(JSON.parse(value)), write: (value: Map) => JSON.stringify([...value]), } ``` ### Set Serializer ```typescript { read: (value: string) => new Set(JSON.parse(value)), write: (value: Set) => JSON.stringify([...value]), } ``` ## Common Use Cases * **User preferences**: Theme, language, font size, layout preferences * **Form drafts**: Auto-save form data to prevent data loss * **Authentication**: Store tokens or session data (consider security implications) * **Shopping cart**: Persist cart items across sessions * **UI state**: Remember expanded/collapsed sections, selected tabs, etc. * **Recent searches**: Store and display recent search queries * **Feature flags**: Store user-specific feature flags * **Analytics**: Track user behavior across sessions ## Security Considerations * **Never store sensitive data**: localStorage is not encrypted and accessible via JavaScript * **Be cautious with tokens**: Consider using httpOnly cookies for sensitive auth tokens * **Validate stored data**: Always validate data read from localStorage as it can be modified by users * **XSS vulnerabilities**: Stored data can be accessed by malicious scripts ## Storage Limits * Most browsers allow **5-10MB** of localStorage per origin * Exceeding quota throws a `QuotaExceededError` (handled gracefully by this composable) * Use compression for large data or consider alternative storage (IndexedDB) ## Source ````ts import { WritableSignal } from '@angular/core'; import { useStorage } from '../use-storage-base/use-storage-base.composable'; import { UseStorageOptions } from '../use-storage-base/types'; /** * Creates a writable signal that automatically syncs with localStorage. The signal persists * its value across page reloads and updates when the storage value changes in other tabs/windows. * * On the server, returns the default value and syncs to actual value once hydrated on the client. * * @param key - localStorage key to store the value under * @param defaultValue - Default value if no stored value exists * @param options - Configuration options * * @example * ```ts * // Simple counter that persists * const counter = useLocalStorage('counter', 0); * counter.set(counter() + 1); * ``` * * @example * ```ts * // User preferences * const prefs = useLocalStorage('user-prefs', { theme: 'light', fontSize: 16 }); * prefs.update(p => ({ ...p, theme: 'dark' })); * ``` * * @example * ```ts * // With custom serializer for Date * const lastVisit = useLocalStorage('last-visit', new Date(), { * serializer: { * read: (value) => new Date(value), * write: (value) => value.toISOString(), * }, * }); * ``` * * @example * ```ts * // Remove from storage by setting to null * const token = useLocalStorage('auth-token', null); * token.set(null); // Removes from localStorage * ``` */ export function useLocalStorage( key: string, defaultValue: T, options: UseStorageOptions = {}, ): WritableSignal { return useStorage('localStorage', key, defaultValue, options); } ```` --- --- url: /ng-reactive-utils/composables/browser/use-media-query.md --- # useMediaQuery Creates a signal that tracks whether a CSS media query matches. The signal automatically updates when the match state changes (e.g., when the window is resized or device orientation changes). **MDN Reference:** [Window.matchMedia()](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) ## Usage ### Basic Usage ```typescript import { useMediaQuery } from 'ng-reactive-utils'; @Component({ template: `
@if (isMobile()) {

Mobile view

} @else {

Desktop view

}
`, }) class ResponsiveComponent { isMobile = useMediaQuery('(max-width: 768px)'); } ``` ### Multiple Media Queries ```typescript @Component({ template: `

Current breakpoint: {{ breakpoint() }}

`, }) class BreakpointComponent { isMobile = useMediaQuery('(max-width: 640px)'); isTablet = useMediaQuery('(min-width: 641px) and (max-width: 1024px)'); isDesktop = useMediaQuery('(min-width: 1025px)'); breakpoint = computed(() => { if (this.isMobile()) return 'mobile'; if (this.isTablet()) return 'tablet'; if (this.isDesktop()) return 'desktop'; return 'unknown'; }); deviceClass = computed(() => `device-${this.breakpoint()}`); } ``` ### Dark Mode Detection ```typescript @Component({ template: `

Theme: {{ prefersDark() ? 'Dark' : 'Light' }}

`, }) class ThemeDetectionComponent { prefersDark = useMediaQuery('(prefers-color-scheme: dark)'); } ``` ### Device Orientation ```typescript @Component({ template: `

Orientation: {{ isPortrait() ? 'Portrait' : 'Landscape' }}

`, }) class OrientationComponent { isPortrait = useMediaQuery('(orientation: portrait)'); isLandscape = useMediaQuery('(orientation: landscape)'); } ``` ### Print Media Query ```typescript @Component({ template: ` @if (isPrint()) {
Print-friendly version
} @else {
Screen version with interactive elements
} `, }) class PrintableComponent { isPrint = useMediaQuery('print'); } ``` ### Reduced Motion Preference ```typescript @Component({ template: `
Content
`, }) class AccessibilityComponent { prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); } ``` ### High Resolution Display ```typescript @Component({ template: ` Logo `, }) class RetinaImageComponent { isHighDPI = useMediaQuery('(min-resolution: 2dppx)'); imageSrc = computed(() => { return this.isHighDPI() ? 'logo@2x.png' : 'logo.png'; }); } ``` ### Responsive Layout with Shared Instance ```typescript @Component({ selector: 'app-header', template: `
Header
`, }) class HeaderComponent { // Shared across all components in the same injector context isMobile = useMediaQuery('(max-width: 768px)'); } @Component({ selector: 'app-sidebar', template: ``, }) class SidebarComponent { // Same query = same shared instance as HeaderComponent isMobile = useMediaQuery('(max-width: 768px)'); } ``` ## Parameters | Parameter | Type | Description | | --------- | -------- | ------------------------------------------------------ | | `query` | `string` | CSS media query string (e.g., '(max-width: 768px)') | ## Returns `Signal` - A readonly signal that is `true` when the media query matches, `false` otherwise ## Notes * **Returned signal is readonly** to prevent direct manipulation * Uses `createSharedComposable` internally - components with the same query string share a single instance * Query strings are normalized (lowercased, whitespace collapsed) for consistent sharing behavior * Different query strings create separate instances with their own `MediaQueryList` listeners * Uses native `window.matchMedia()` API for efficient media query matching * Event listeners are **automatically cleaned up** when no more subscribers * On the server, returns `false` by default and updates to actual value once hydrated on the client * Media query syntax follows standard CSS media query rules * Supports all standard media features: width, height, orientation, resolution, color-scheme, etc. * **Invalid media queries**: The browser's `matchMedia()` API does not throw errors for invalid queries. Instead, invalid queries will always return `false`. Ensure your query syntax is correct to avoid unexpected behavior. ## Common Media Queries ### Breakpoints * `(max-width: 640px)` - Mobile * `(min-width: 641px) and (max-width: 1024px)` - Tablet * `(min-width: 1025px)` - Desktop * `(min-width: 1280px)` - Large desktop ### User Preferences * `(prefers-color-scheme: dark)` - Dark mode preference * `(prefers-color-scheme: light)` - Light mode preference * `(prefers-reduced-motion: reduce)` - Reduced motion preference * `(prefers-contrast: high)` - High contrast preference ### Device Features * `(orientation: portrait)` - Portrait orientation * `(orientation: landscape)` - Landscape orientation * `(hover: hover)` - Device supports hover interactions * `(pointer: fine)` - Device has precise pointing (mouse) * `(pointer: coarse)` - Device has imprecise pointing (touch) ### Display Quality * `(min-resolution: 2dppx)` - Retina/high-DPI displays * `(min-resolution: 192dpi)` - Same as above, different unit ### Print * `print` - Print media type * `screen` - Screen media type ## Common Use Cases * **Responsive design**: Show/hide elements based on screen size * **Dark mode**: Detect and respond to system theme preferences * **Accessibility**: Respect user preferences for reduced motion or high contrast * **Device optimization**: Load different assets or layouts for mobile vs desktop * **Print styles**: Display print-friendly content when printing * **Progressive enhancement**: Detect device capabilities and adjust features accordingly ## Source ````ts import { signal, inject, PLATFORM_ID } from '@angular/core'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { createSharedComposable } from '../../../utils/create-shared-composable/create-shared-composable'; /** * Creates a signal that tracks whether a CSS media query matches. The signal automatically * updates when the match state changes (e.g., when the window is resized or device orientation changes). * * This composable is shared across components with the same query string in the same injector context. * Components using the same query share one instance; different queries create separate instances. * Query strings are normalized (lowercased, whitespace collapsed) for consistent sharing. * * On the server, returns `false` by default and updates to actual value once hydrated on the client. * * **Note**: Invalid media query strings will not throw errors but will always return `false`. * The browser's `matchMedia()` API silently accepts invalid queries. Ensure your query syntax is correct. * * @param query - CSS media query string (e.g., '(max-width: 768px)') * * @example * ```ts * // Basic responsive breakpoint * const isMobile = useMediaQuery('(max-width: 768px)'); * ``` * * @example * ```ts * // Dark mode detection * const prefersDark = useMediaQuery('(prefers-color-scheme: dark)'); * ``` * * @example * ```ts * // Orientation detection * const isPortrait = useMediaQuery('(orientation: portrait)'); * ``` * * @example * ```ts * // High DPI display detection * const isHighDPI = useMediaQuery('(min-resolution: 2dppx)'); * ``` */ // Factory is module-scoped so its internal cache is shared correctly across all consumers. // Previously this was created inside useMediaQuery(), which gave every call its own cache. const sharedMediaQuery = createSharedComposable((normalizedQuery: string) => { const document = inject(DOCUMENT); const platformId = inject(PLATFORM_ID); const isBrowser = isPlatformBrowser(platformId); const getInitialMatches = () => { if (!isBrowser || !document.defaultView) { return false; } return document.defaultView.matchMedia(normalizedQuery).matches; }; const matchesSignal = signal(getInitialMatches()); let mediaQueryList: MediaQueryList | null = null; const handleChange = (event: MediaQueryListEvent) => { matchesSignal.set(event.matches); }; // Only set up media query listener in the browser if (isBrowser && document.defaultView) { mediaQueryList = document.defaultView.matchMedia(normalizedQuery); mediaQueryList.addEventListener('change', handleChange); } return { value: matchesSignal.asReadonly(), cleanup: () => { if (mediaQueryList) { mediaQueryList.removeEventListener('change', handleChange); } }, }; }); export const useMediaQuery = (query: string) => { // Normalize query before passing to the shared factory so the cache key is consistent const normalizedQuery = query.toLowerCase().replace(/\s+/g, ' ').trim(); return sharedMediaQuery(normalizedQuery); }; ```` --- --- url: /ng-reactive-utils/composables/browser/use-mouse-position.md --- # useMousePosition Creates signals that track the mouse position (x and y coordinates). The signals update when the mouse moves, with throttling to prevent excessive updates. **MDN Reference:** [MouseEvent](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent) ## Usage ```typescript import { useMousePosition } from 'ng-reactive-utils'; @Component({ template: `
Mouse: {{ mousePosition().x }}, {{ mousePosition().y }}
`, }) class CursorTrackerComponent { mousePosition = useMousePosition(); } ``` ### With Custom Throttle ```typescript @Component({ template: `
Mouse: {{ mousePosition().x }}, {{ mousePosition().y }}
`, }) class SmoothCursorComponent { // Use a longer throttle for smoother updates mousePosition = useMousePosition(200); } ``` ## Parameters | Parameter | Type | Default | Description | | ------------ | -------- | ------- | ----------------------------------------- | | `throttleMs` | `number` | `100` | Throttle delay for mouse move events (ms) | ## Returns `Signal<{ x: number; y: number }>` - A readonly signal containing mouse coordinates ## Notes * Returned signal is **readonly** to prevent direct manipulation * Uses `createSharedComposable` internally - usages with the same `throttleMs` value share a single instance * Different `throttleMs` values create separate instances with their own event listeners * Throttles mouse move events by default (100ms) to prevent excessive updates * Event listeners are automatically cleaned up when no more subscribers ## Source ````ts import { signal, inject, PLATFORM_ID } from '@angular/core'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import throttle from 'lodash-es/throttle'; import { createSharedComposable } from '../../../utils/create-shared-composable/create-shared-composable'; export type MousePosition = { x: number; y: number }; /** * Creates signals that track the mouse position (x and y coordinates). The signals update * when the mouse moves, with throttling to prevent excessive updates. * * This composable is shared across components with the same parameters in the same injector context. * Components using the same throttle value share one instance; different values create separate instances. * * On the server, returns default values (0, 0) and updates to actual values once hydrated on the client. * * @param throttleMs - Throttle delay for mouse move events (default: 100ms) * * @example * ```ts * // Default throttle (100ms) - shares instance with other components using default * const mousePosition = useMousePosition(); * const { x, y } = mousePosition(); * ``` * * @example * ```ts * // Custom throttle (200ms) - creates separate instance for this throttle value * const mousePosition = useMousePosition(200); * ``` */ export const useMousePosition = createSharedComposable((throttleMs: number = 100) => { const document = inject(DOCUMENT); const platformId = inject(PLATFORM_ID); const isBrowser = isPlatformBrowser(platformId); const mousePosition = signal({ x: 0, y: 0 }); const updatePosition = (event: MouseEvent) => { mousePosition.set({ x: event.clientX, y: event.clientY }); }; const throttledUpdatePosition = throttle(updatePosition, throttleMs); // Only set up event listeners in the browser if (isBrowser && document.defaultView) { document.defaultView.addEventListener('mousemove', throttledUpdatePosition); } return { value: mousePosition.asReadonly(), cleanup: () => { throttledUpdatePosition.cancel(); if (isBrowser && document.defaultView) { document.defaultView.removeEventListener('mousemove', throttledUpdatePosition); } }, }; }); ```` --- --- url: /ng-reactive-utils/composables/general/use-previous-signal.md --- # usePreviousSignal Creates a signal that tracks the previous value of a source signal. Useful for comparing current vs previous state or implementing undo functionality. ## Usage ```typescript import { usePreviousSignal } from 'ng-reactive-utils'; @Component({ template: `

Current: {{ count() }}

Previous: {{ previousCount() }}

`, }) class ExampleComponent { count = signal(0); previousCount = usePreviousSignal(this.count); increment() { this.count.update((v) => v + 1); } } ``` ## Parameters | Parameter | Type | Default | Description | | -------------- | ----------- | ---------- | ----------------------------------------- | | `sourceSignal` | `Signal` | *required* | The source signal to track previous value | ## Returns `Signal` - A readonly signal containing the previous value (undefined initially) ## Notes * The previous signal starts with `undefined` on first read * Updates to track the previous value whenever the source changes * Returned signal is **readonly** to prevent direct manipulation ## Source ```ts import { Signal, effect, signal } from '@angular/core'; /* * Creates a signal that tracks the previous value of a source signal. Useful for comparing * current vs previous state or implementing undo functionality. * * @param sourceSignal - The source signal to track the previous value of. * * Example: * * const currentValue = signal('hello'); * const previousValue = usePreviousSignal(currentValue); * * // previousValue() will be undefined initially, then track the previous value * console.log(previousValue()); // undefined * currentValue.set('world'); * console.log(previousValue()); // 'hello' */ export function usePreviousSignal(sourceSignal: Signal): Signal { const previousSignal = signal(undefined); let lastValue: T | undefined = undefined; let isFirstRun = true; // Track changes via effect to avoid side effects inside computed effect(() => { const currentValue = sourceSignal(); if (!isFirstRun) { previousSignal.set(lastValue); } lastValue = currentValue; isFirstRun = false; }); return previousSignal.asReadonly(); } ``` --- --- url: /ng-reactive-utils/composables/route/use-route-data.md --- # useRouteData Exposes route data as a signal-based object. This is useful when you need to access route data reactively, such as for permissions, page titles, or custom metadata attached to routes. ## Usage ```typescript import { useRouteData } from 'ng-reactive-utils'; // Route config: { path: 'admin', data: { role: 'admin', title: 'Admin Panel' } } @Component({ template: `

{{ routeData().title }}

`, }) class AdminComponent { routeData = useRouteData<{ role: string; title: string }>(); hasAccess = computed(() => this.routeData().role === 'admin'); } ``` ## Returns `Signal` - A readonly signal containing route data (empty object if no data) ## Notes * Uses `toSignal` with `route.data` observable * Returns an empty object `{}` if no route data is present * Type parameter `T` allows for type-safe access to route data properties * Updates reactively when route data changes ## Source ```ts import { inject, Signal, computed } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; export const useRouteData = () => { const route = inject(ActivatedRoute); const routeData = toSignal(route.data, { initialValue: route.snapshot.data }) as Signal; return computed(() => routeData() ?? ({} as T)); }; ``` --- --- url: /ng-reactive-utils/composables/route/use-route-fragment.md --- # useRouteFragment Exposes the route fragment (the part after #) as a signal. This is useful for implementing smooth scrolling to sections, deep linking, or tracking which section of a page is active. ## Usage ```typescript import { useRouteFragment } from 'ng-reactive-utils'; // URL: /docs#installation @Component({ template: `

Current section: {{ fragment() }}

`, }) class DocumentationComponent { fragment = useRouteFragment(); constructor() { effect(() => { const section = this.fragment(); if (section) { document.getElementById(section)?.scrollIntoView({ behavior: 'smooth' }); } }); } } ``` ## Returns `Signal` - A readonly signal containing the route fragment (null if no fragment) ## Notes * Uses `toSignal` with `route.fragment` observable * Returns `null` when no fragment is present in the URL * Updates reactively when the fragment changes * Perfect for implementing smooth scrolling or highlighting active sections ## Source ```ts import { inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; export const useRouteFragment = () => { const route = inject(ActivatedRoute); return toSignal(route.fragment, { initialValue: route.snapshot.fragment }); }; ``` --- --- url: /ng-reactive-utils/composables/route/use-route-param.md --- # useRouteParam A convenience function that returns a single route parameter as a signal. This is useful when you only need to access one specific parameter from the route. ## Usage ```typescript import { useRouteParam } from 'ng-reactive-utils'; // Route: /products/:productId @Component({ template: `

Product ID: {{ productId() }}

`, }) class ProductDetailComponent { productId = useRouteParam('productId'); productResource = resource({ params: () => ({ id: this.productId() }), loader: ({ params }) => fetchProduct(params.id), }); } ``` ## Parameters | Parameter | Type | Default | Description | | ----------- | -------- | ---------- | ------------------------------- | | `paramName` | `string` | *required* | The name of the route parameter | ## Returns `Signal` - A readonly signal containing the parameter value ## Notes * Delegates to `useRouteParams` and wraps the result in a `computed` signal to extract a single key * Updates reactively when the route parameter changes * Type parameter `T` is **constrained** to `string | null | undefined` — it does not default to that union; calling without a type argument infers the full constraint as the type ## Source ```ts import { computed } from '@angular/core'; import { useRouteParams } from '../use-route-params/use-route-params.composable'; export const useRouteParam = (paramName: string) => { const parameters = useRouteParams(); return computed(() => parameters()[paramName] as T); }; ``` --- --- url: /ng-reactive-utils/composables/route/use-route-params.md --- # useRouteParams A convenience function that wraps Angular's ActivatedRoute.params, exposing all route parameters as a signal-based object. This is useful when you need to access multiple route parameters at once or work with the entire parameter object reactively. ## Usage ```typescript import { useRouteParams } from 'ng-reactive-utils'; // Route: /users/:userId/posts/:postId @Component({ template: `

User {{ params().userId }} - Post {{ params().postId }}

`, }) class PostDetailComponent { params = useRouteParams<{ userId: string; postId: string }>(); postResource = resource({ params: () => this.params(), loader: ({ params }) => fetchPost(params.userId, params.postId), }); } ``` ## Returns `Signal` - A readonly signal containing all route parameters as an object ## Notes * Uses `toSignal` with `route.params` observable * Type parameter `T` defaults to `{ [key: string]: string | null }` * Updates reactively when any route parameter changes * For single parameter access, consider using `useRouteParam` instead ## Source ```ts import { Signal, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; export const useRouteParams = () => { const route = inject(ActivatedRoute); return toSignal(route.params, { initialValue: route.snapshot.params, }) as Signal; }; ``` --- --- url: /ng-reactive-utils/composables/route/use-route-query-param.md --- # useRouteQueryParam A convenience function that returns a single query parameter as a signal. This is useful when you only need to access one specific query parameter from the URL. ## Usage ```typescript import { useRouteQueryParam } from 'ng-reactive-utils'; // URL: /search?query=angular&sort=date @Component({ template: `

Searching for: {{ searchQuery() }}

`, }) class SearchComponent { searchQuery = useRouteQueryParam('query'); searchResults = resource({ params: () => ({ q: this.searchQuery() }), loader: ({ params }) => fetchSearchResults(params.q), }); } ``` ## Parameters | Parameter | Type | Default | Description | | ----------- | -------- | ---------- | ------------------------------- | | `paramName` | `string` | *required* | The name of the query parameter | ## Returns `Signal` - A readonly signal containing the query parameter value ## Notes * Delegates to `useRouteQueryParams` and wraps the result in a `computed` signal to extract a single key * Updates reactively when the query parameter changes * Type parameter `T` is **constrained** to `string | undefined` — it does not default to that union; calling without a type argument infers the full constraint as the type * Returns `undefined` when the query parameter is not present in the URL ## Source ```ts import { computed } from '@angular/core'; import { useRouteQueryParams } from '../use-route-query-params/use-route-query-params.composable'; export const useRouteQueryParam = (paramName: string) => { const queryParams = useRouteQueryParams(); return computed(() => queryParams()[paramName] as T); }; ``` --- --- url: /ng-reactive-utils/composables/route/use-route-query-params.md --- # useRouteQueryParams Exposes all query parameters as a signal-based object. This is useful when you need to access multiple query parameters at once or work with the entire query parameter object reactively. ## Usage ```typescript import { useRouteQueryParams } from 'ng-reactive-utils'; // URL: /products?category=electronics&sort=price&order=asc @Component({ template: `

Category: {{ queryParams().category }}

`, }) class ProductListComponent { queryParams = useRouteQueryParams<{ category?: string; sort?: string; order?: string }>(); productsResource = resource({ params: () => this.queryParams(), loader: ({ params }) => fetchProducts(params), }); } ``` ## Returns `Signal` - A readonly signal containing all query parameters as an object ## Notes * Uses `toSignal` with `route.queryParams` observable * Type parameter `T` defaults to `{ [key: string]: string | undefined }` * Updates reactively when any query parameter changes * For single parameter access, consider using `useRouteQueryParam` instead ## Source ```ts import { inject, Signal } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; export const useRouteQueryParams = () => { const route = inject(ActivatedRoute); return toSignal(route.queryParams, { initialValue: route.snapshot.queryParams, }) as Signal; }; ``` --- --- url: /ng-reactive-utils/composables/browser/use-session-storage.md --- # useSessionStorage Creates a writable signal that automatically syncs with sessionStorage. The signal persists its value during the page session (until the tab/window is closed) but does not sync across tabs like localStorage. **MDN Reference:** [Window.sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) ## Usage ### Basic Usage ```typescript import { useSessionStorage } from 'ng-reactive-utils'; @Component({ template: `

Page views: {{ pageViews() }}

`, }) class PageViewsComponent { // Value persists during the session (until tab is closed) pageViews = useSessionStorage('page-views', 0); } ``` ### Wizard/Multi-Step Form State ```typescript interface WizardState { currentStep: number; completedSteps: number[]; formData: { personalInfo: { name: string; email: string }; preferences: { newsletter: boolean; theme: string }; }; } @Component({ template: `

Step {{ wizardState().currentStep }} of 3

@if (wizardState().currentStep === 1) { } @if (wizardState().currentStep === 2) { }
`, }) class WizardComponent { wizardState = useSessionStorage('wizard-state', { currentStep: 1, completedSteps: [], formData: { personalInfo: { name: '', email: '' }, preferences: { newsletter: false, theme: 'light' }, }, }); nextStep() { this.wizardState.update((state) => ({ ...state, currentStep: state.currentStep + 1, completedSteps: [...state.completedSteps, state.currentStep], })); } } ``` ### Temporary Filters ```typescript interface FilterState { category: string; priceRange: { min: number; max: number }; sortBy: string; } @Component({ template: `
`, }) class ProductFiltersComponent { // Filters persist during browsing session but reset when tab closes filters = useSessionStorage('product-filters', { category: 'all', priceRange: { min: 0, max: 1000 }, sortBy: 'name', }); updateCategory(category: string) { this.filters.update((f) => ({ ...f, category })); } resetFilters() { this.filters.set({ category: 'all', priceRange: { min: 0, max: 1000 }, sortBy: 'name', }); } } ``` ### Navigation History ```typescript @Component({ template: `

Pages visited: {{ visitedPages().length }}

`, }) class NavigationComponent { visitedPages = useSessionStorage('visited-pages', []); canGoBack = computed(() => this.visitedPages().length > 1); constructor() { const router = inject(Router); // Track page visits effect(() => { const currentUrl = router.url; this.visitedPages.update((pages) => [...pages, currentUrl]); }); } goBack() { const pages = this.visitedPages(); if (pages.length > 1) { // Navigate to previous page const previousPage = pages[pages.length - 2]; // ... router.navigateByUrl(previousPage); } } } ``` ### Temporary Authentication ```typescript @Component({ template: `
@if (sessionToken()) {

Logged in

} @else { }
`, }) class SessionAuthComponent { // Token only persists for this tab session sessionToken = useSessionStorage('session-token', null); login() { this.sessionToken.set('temp-session-abc123'); } logout() { this.sessionToken.set(null); } } ``` ### Scroll Position Restoration ```typescript @Component({ template: `
...
`, }) class ScrollRestoreComponent { scrollPosition = useSessionStorage('scroll-position', { x: 0, y: 0 }); ngAfterViewInit() { // Restore scroll position const { x, y } = this.scrollPosition(); window.scrollTo(x, y); } saveScrollPosition(event: Event) { const element = event.target as HTMLElement; this.scrollPosition.set({ x: element.scrollLeft, y: element.scrollTop, }); } } ``` ### Tab-Specific Settings ```typescript @Component({ template: `
`, }) class TabSettingsComponent { // Setting only applies to this tab devMode = useSessionStorage('dev-mode', false); } ``` ### Custom Serialization with Date ```typescript interface Session { startTime: Date; userId: string; actions: string[]; } @Component({ template: `

Session started: {{ session().startTime.toLocaleString() }}

Actions: {{ session().actions.length }}

`, }) class SessionTrackerComponent { session = useSessionStorage( 'user-session', { startTime: new Date(), userId: '', actions: [], }, { serializer: { read: (value: string) => { const parsed = JSON.parse(value); return { ...parsed, startTime: new Date(parsed.startTime), }; }, write: (value: Session) => JSON.stringify({ ...value, startTime: value.startTime.toISOString(), }), }, } ); } ``` ## Parameters | Parameter | Type | Description | | --------------- | ------------------------------- | ----------------------------------------------------- | | `key` | `string` | sessionStorage key to store the value under | | `defaultValue` | `T` | Default value if no stored value exists | | `options` | `UseStorageOptions` (optional) | Configuration options | ### Options Object | Property | Type | Default | Description | | --------------------------- | ----------------------------------------------------- | -------------- | -------------------------------------------------------------- | | `serializer` | `{ read: (value: string) => T; write: (value: T) => string }` | JSON serializer | Custom serialization logic | | `writeDefaults` | `boolean` | `true` | Write default value to storage on initialization if not present | ## Returns `WritableSignal` - A writable signal that syncs with sessionStorage ## Notes * **Writable signal**: Returned signal can be updated using `.set()` and `.update()` * **Automatic persistence**: Changes to the signal automatically save to sessionStorage * **Session-scoped**: Data is cleared when the tab/window is closed * **Tab-isolated**: Each tab has its own sessionStorage (unlike localStorage) * **SSR safe**: Returns default value on server, hydrates from sessionStorage on client * **Type safe**: Full TypeScript support with generic type parameter * **JSON by default**: Uses `JSON.stringify`/`JSON.parse` for serialization by default * **Custom serialization**: Provide custom serializer for complex types (Date, Map, Set, etc.) * **Null removes**: Setting the value to `null` removes the key from sessionStorage * **Error handling**: Gracefully handles quota exceeded errors and parse errors * **Storage events**: Automatically listens to `storage` events (though rarely triggered for sessionStorage) * **Automatic cleanup**: Storage event listeners are removed when component is destroyed ## sessionStorage vs localStorage | Feature | sessionStorage | localStorage | | ---------------------- | ------------------------------------- | ------------------------------- | | **Lifetime** | Until tab/window closes | Persists forever | | **Scope** | Per tab/window | Shared across all tabs | | **Storage events** | Rarely needed (tab-isolated) | Essential for cross-tab sync | | **Use cases** | Temporary data, wizard state, filters | Preferences, auth, long-term data | | **Size limit** | ~5-10MB (same as localStorage) | ~5-10MB | ## Common Use Cases * **Multi-step forms/wizards**: Preserve form data during the session * **Temporary filters**: Store filter/sort state during browsing * **Tab-specific settings**: Settings that only apply to current tab * **Session tracking**: Track user actions during the current session * **Navigation history**: Remember where the user has been in this tab * **Scroll position**: Restore scroll position on back navigation * **Draft data**: Auto-save drafts that should be discarded when tab closes * **Temporary authentication**: Short-lived session tokens ## When to Use sessionStorage vs localStorage Use **sessionStorage** when: * ✅ Data should be cleared when the tab closes * ✅ Data is specific to a single tab/window * ✅ You want to prevent data from persisting too long * ✅ Working with temporary wizard/form state Use **localStorage** when: * ✅ Data should persist across sessions * ✅ You need to sync data across tabs * ✅ Storing user preferences or settings * ✅ Data needs to survive browser restarts ## Source ````ts import { WritableSignal } from '@angular/core'; import { useStorage } from '../use-storage-base/use-storage-base.composable'; import { UseStorageOptions } from '../use-storage-base/types'; /** * Creates a writable signal that automatically syncs with sessionStorage. The signal persists * its value during the page session (until the tab/window is closed) but does not sync across * tabs like localStorage. * * On the server, returns the default value and syncs to actual value once hydrated on the client. * * @param key - sessionStorage key to store the value under * @param defaultValue - Default value if no stored value exists * @param options - Configuration options * * @example * ```ts * // Wizard state that clears on tab close * const wizardStep = useSessionStorage('wizard-step', 1); * wizardStep.set(wizardStep() + 1); * ``` * * @example * ```ts * // Temporary filters * const filters = useSessionStorage('filters', { category: 'all', sortBy: 'name' }); * filters.update(f => ({ ...f, category: 'electronics' })); * ``` */ export function useSessionStorage( key: string, defaultValue: T, options: UseStorageOptions = {}, ): WritableSignal { return useStorage('sessionStorage', key, defaultValue, options); } ```` --- --- url: /ng-reactive-utils/composables/browser/use-window-size.md --- # useWindowSize Creates signals that track the window size (width and height). The signals update when the window is resized, with debouncing to prevent excessive updates. ## Usage ```typescript import { useWindowSize } from 'ng-reactive-utils'; @Component({ template: `

Window: {{ windowSize().width }}px × {{ windowSize().height }}px

Is mobile: {{ isMobile() }}

`, }) class ResponsiveComponent { windowSize = useWindowSize(); isMobile = computed(() => this.windowSize().width < 768); } ``` ### With Custom Debounce ```typescript @Component({ template: `

Window: {{ windowSize().width }}px × {{ windowSize().height }}px

`, }) class SlowDebounceComponent { // Use a longer debounce for less frequent updates windowSize = useWindowSize(300); } ``` ## Parameters | Parameter | Type | Default | Description | | ------------ | -------- | ------- | ------------------------------------- | | `debounceMs` | `number` | `100` | Debounce delay for resize events (ms) | ## Returns `Signal<{ width: number; height: number }>` - A readonly signal containing window dimensions ## Notes * Returned signal is **readonly** to prevent direct manipulation * Uses `createSharedComposable` internally - components with the same `debounceMs` value share a single instance * Different `debounceMs` values create separate instances with their own event listeners * Debounces resize events by default (100ms) to prevent excessive updates * Event listeners are automatically cleaned up when no more subscribers ## Source ````ts import { signal, inject, PLATFORM_ID } from '@angular/core'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { debounce } from 'lodash-es'; import { createSharedComposable } from '../../../utils/create-shared-composable/create-shared-composable'; export type WindowSize = { width: number; height: number; }; /** * Creates signals that track the window size (width and height). The signals update * when the window is resized, with debouncing to prevent excessive updates. * * This composable is shared across components with the same parameters in the same injector context. * Components using the same debounce value share one instance; different values create separate instances. * * On the server, returns default values (0, 0) and updates to actual values once hydrated on the client. * * @param debounceMs - Debounce delay for resize events (default: 100ms) * * @example * ```ts * // Default debounce (100ms) - shares instance with other components using default * const windowSize = useWindowSize(); * const { width, height } = windowSize(); * ``` * * @example * ```ts * // Custom debounce (300ms) - creates separate instance for this debounce value * const windowSize = useWindowSize(300); * ``` */ export const useWindowSize = createSharedComposable((debounceMs: number = 100) => { const document = inject(DOCUMENT); const platformId = inject(PLATFORM_ID); const isBrowser = isPlatformBrowser(platformId); const getWindowSize = (): WindowSize => ({ width: document.defaultView?.innerWidth ?? 0, height: document.defaultView?.innerHeight ?? 0, }); const windowSizeSignal = signal(getWindowSize()); const handleResize = () => windowSizeSignal.set(getWindowSize()); const debouncedHandleResize = debounce(handleResize, debounceMs); // Only set up event listeners in the browser if (isBrowser && document.defaultView) { document.defaultView.addEventListener('resize', debouncedHandleResize); } // Cleanup and return readonly signal return { value: windowSizeSignal.asReadonly(), cleanup: () => { if (isBrowser && document.defaultView) { document.defaultView.removeEventListener('resize', debouncedHandleResize); } debouncedHandleResize.cancel(); }, }; }); ```` --- --- url: /ng-reactive-utils/composables/general/when.md --- # when · whenTrue · whenFalse Run a side effect callback when a signal satisfies a condition. * Only the source signal drives re-execution — the callback is wrapped in `untracked` automatically * Returns a cancel function that can be stored as a class field and called from a method **The problem they replace:** ```typescript // Easy to get wrong — untracked is easy to forget, effects grow noisy over time effect(() => { if (this.isOpen()) { untracked(() => { this.dashboardCopy.set(cloneDeep(this.dashboard())); }); } }); // Clear trigger, untracked handled for you whenTrue(this.isOpen, () => { this.dashboardCopy.set(cloneDeep(this.dashboard())); }); ``` ## Usage ### Sidebar with Setup and Teardown ```typescript import { whenTrue, whenFalse } from 'ng-reactive-utils'; @Component({ template: `...`, }) class EditSidebarComponent { isOpen = input(false); dashboard = input(null); dashboardCopy = signal(null); onOpen = whenTrue(this.isOpen, () => { this.dashboardCopy.set(cloneDeep(this.dashboard())); this.fetchMetadata(); }); onClose = whenFalse(this.isOpen, () => { this.dashboardCopy.set(null); }); } ``` ### Custom Condition with `when` Use `when` with a predicate for any condition beyond truthy/falsy. ```typescript import { when } from 'ng-reactive-utils'; type UploadStatus = 'idle' | 'uploading' | 'complete' | 'error'; @Component({ template: `...` }) class FileUploadComponent { uploadStatus = signal('idle'); onUploadComplete = when( this.uploadStatus, (status) => status === 'complete', () => { this.showSuccessToast(); this.refreshFileList(); }, ); onUploadError = when( this.uploadStatus, (status) => status === 'error', () => { this.showErrorDialog(); }, ); } ``` ### Early Cancellation ```typescript import { whenTrue } from 'ng-reactive-utils'; @Component({ template: `...` }) class OnboardingComponent { isGuideVisible = signal(false); stepCount = signal(0); onGuideVisible = whenTrue(this.isGuideVisible, () => { this.stepCount.update((n) => n + 1); }); permanentlyDismiss() { this.isGuideVisible.set(false); this.onGuideVisible(); // cancel — no longer reacts to future changes } } ``` ## Parameters ### `when` | Parameter | Type | Description | | ----------- | ----------------------- | --------------------------------------------- | | `source` | `Signal` | The signal to watch | | `predicate` | `(value: T) => boolean` | Condition that determines when callback fires | | `callback` | `() => void` | Runs each time the predicate returns `true` | ### `whenTrue` · `whenFalse` | Parameter | Type | Description | | ---------- | ----------------- | ---------------------------------------------- | | `source` | `Signal` | The signal to watch | | `callback` | `() => void` | Runs each time the signal becomes truthy/falsy | ## Returns `() => void` — A cancel function that stops the effect immediately. Safe to call multiple times. Automatic cleanup on component destroy still applies. ## Notes * **Automatic `untracked`**: Signals read inside the callback do not create reactive dependencies. If you need reactive behavior inside the callback, use a separate `effect()`. * **Runs eagerly**: If the condition is already met at creation time, the callback fires on the first effect execution. * **Runs every time**: Fires each time the condition becomes true, not just the first time. For run-once behavior, call the cancel function inside the callback. * **Truthy semantics**: `whenTrue` uses `Boolean(value)` — `''`, `0`, `null`, and `undefined` are treated as falsy. * **Injection context required**: Must be called as a class field initializer, in a constructor, or within `runInInjectionContext`. ## Source ```ts import { DestroyRef, Signal, effect, inject, untracked } from '@angular/core'; /** * Runs a callback whenever a signal satisfies a predicate condition. The callback is automatically * wrapped in `untracked` so that any signals read or written inside it do not create additional * reactive dependencies — only the source signal drives re-execution. * * @param source - The signal to watch * @param predicate - A function that receives the signal's current value and returns true when the callback should run * @param callback - The side effect to run when the predicate is satisfied * @returns A cancel function that stops the effect immediately. Safe to call multiple times. * * @example * onUploadComplete = when(this.uploadStatus, (status) => status === 'complete', () => { * this.showSuccessToast(); * }); */ export function when( source: Signal, predicate: (value: T) => boolean, callback: () => void, ): () => void { const destroyRef = inject(DestroyRef); const effectRef = effect(() => { if (predicate(source())) { untracked(callback); } }); let isCancelled = false; const cancel = () => { if (isCancelled) return; isCancelled = true; effectRef.destroy(); }; destroyRef.onDestroy(cancel); return cancel; } /** * Runs a callback each time a signal becomes truthy. The callback is automatically wrapped in * `untracked` so that any signals read or written inside it do not create additional reactive * dependencies — only the source signal drives re-execution. * * @param source - The signal to watch * @param callback - The side effect to run when the signal becomes truthy * @returns A cancel function that stops the effect immediately. Safe to call multiple times. * * @example * onOpen = whenTrue(this.isOpen, () => { * this.dashboardCopy.set(cloneDeep(this.dashboard())); * }); */ export function whenTrue(source: Signal, callback: () => void): () => void { return when(source, Boolean, callback); } /** * Runs a callback each time a signal becomes falsy. The callback is automatically wrapped in * `untracked` so that any signals read or written inside it do not create additional reactive * dependencies — only the source signal drives re-execution. * * @param source - The signal to watch * @param callback - The side effect to run when the signal becomes falsy * @returns A cancel function that stops the effect immediately. Safe to call multiple times. * * @example * onClose = whenFalse(this.isOpen, () => { * this.dashboardCopy.set(null); * }); */ export function whenFalse(source: Signal, callback: () => void): () => void { return when(source, (value) => !value, callback); } ``` --- --- url: /ng-reactive-utils/getting-started/working-with-forms-and-routes.md --- # Working with Forms and Routes Angular's reactive forms and router use observables. When building signal-based components, you need to convert these observables to signals. While Angular provides `toSignal()` for this, it quickly becomes repetitive and error-prone. NG Reactive Utils provides specialized composables for the most common conversions: **forms** and **routes**. ## The Problem with toSignal() Converting form and route observables to signals requires repetitive boilerplate: ```typescript import { toSignal } from '@angular/core/rxjs-interop'; import { map } from 'rxjs'; @Component({...}) class MyComponent { private route = inject(ActivatedRoute); // Repetitive patterns everywhere userId = toSignal( this.route.params.pipe(map(params => params['id'])), { initialValue: this.route.snapshot.params['id'] } ); formValue = toSignal(this.form.valueChanges, { initialValue: this.form.value }); formValid = toSignal( this.form.statusChanges.pipe(map(() => this.form.valid)), { initialValue: this.form.valid } ); } ``` **Issues:** * Repetitive `toSignal()` calls with initial values * Easy to forget initial values or use wrong observables * Inconsistent patterns across the codebase ## The Solution: Specialized Composables NG Reactive Utils provides composables that eliminate the boilerplate: ### Form State Get complete form state with a single composable: ```typescript import { useFormState } from 'ng-reactive-utils'; @Component({ template: `
@if (formState.dirty()) { You have unsaved changes }
`, }) class UserFormComponent { form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email]), }); formState = useFormState<{ email: string }>(this.form); // Access: formState.value(), formState.valid(), formState.dirty(), etc. } ``` ### Form Controls ```typescript import { useControlState } from 'ng-reactive-utils'; @Component({ template: ` @if (emailState.invalid() && emailState.touched()) { Invalid email } `, }) class MyComponent { emailControl = new FormControl('', [Validators.required, Validators.email]); emailState = useControlState(this.emailControl); } ``` ### Route Parameters ```typescript import { useRouteParam, useRouteParams } from 'ng-reactive-utils'; @Component({ template: `

User: {{ userId() }}

`, }) class UserProfileComponent { userId = useRouteParam('id'); } // Or get all params at once @Component({ template: `

User {{ params().userId }} - Post {{ params().postId }}

`, }) class PostDetailComponent { params = useRouteParams<{ userId: string; postId: string }>(); } ``` ### Query Parameters ```typescript import { useRouteQueryParam } from 'ng-reactive-utils'; @Component({ template: `

Search: {{ searchTerm() }}

`, }) class SearchComponent { searchTerm = useRouteQueryParam('q'); page = useRouteQueryParam('page'); } ``` ### Route Data ```typescript import { useRouteData } from 'ng-reactive-utils'; @Component({...}) class ProductComponent { routeData = useRouteData<{ product: Product }>(); product = computed(() => this.routeData().product); } ``` ## Real-World Example Combining form and route utilities in a search component: ```typescript import { Component, computed } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { useFormState, useRouteQueryParam } from 'ng-reactive-utils'; @Component({ selector: 'app-search', template: `
@if (formState.invalid() && formState.touched()) { Please enter a search query }

Results for: {{ queryParam() || 'all' }}

`, }) export class SearchComponent { form = new FormGroup({ query: new FormControl('', Validators.required), category: new FormControl(''), }); formState = useFormState<{ query: string; category: string }>(this.form); queryParam = useRouteQueryParam('q'); } ``` ## Key Benefits * **Less boilerplate** - No repetitive `toSignal()` calls * **Type-safe** - Full TypeScript support with proper inference * **Correct initial values** - Handled automatically from snapshots * **Consistent API** - Same pattern across all utilities ## Available Composables ### Forms * [`useFormState()`](/composables/form/use-form-state) - Complete form state * [`useFormValue()`](/composables/form/use-form-value) - Form value * [`useFormValid()`](/composables/form/use-form-valid) - Validity status * [`useControlState()`](/composables/control/use-control-state) - Control state * [View all form composables →](/composables/form/use-form-state) ### Routes * [`useRouteParam()`](/composables/route/use-route-param) - Single parameter * [`useRouteParams()`](/composables/route/use-route-params) - All parameters * [`useRouteQueryParam()`](/composables/route/use-route-query-param) - Single query param * [`useRouteData()`](/composables/route/use-route-data) - Route data * [View all route composables →](/composables/route/use-route-param) ## Next Steps * Explore [Browser Composables](/composables/browser/use-window-size) for window size, mouse position, and more * Check out [General Composables](/composables/general/use-previous-signal) for previous value tracking and more