Linked Signals (v19) π
Linked signals are like having a smart assistant that automatically updates one piece of information whenever related information changes, but unlike computed signals, you can also manually override the values when needed. Think of it as a computed signal that you can write to!
π― What Are Linked Signals?
Section titled βπ― What Are Linked Signals?βLinkedSignal is Angular 19βs game-changing feature that creates writable signals derived from other signals. Itβs similar to computed(), but the key difference is that computed() returns a read-only signal, whereas linkedSignal() returns a writable signal.
Key Benefits:
- Writable Computed - Derive values but still allow manual updates
- Signal Input Support - Perfect for working with input signals
- Conditional Logic - Handle complex state synchronization
- Previous State Access - Access both current and previous values
- Multiple Sources - Combine multiple signals into one
π LinkedSignal vs Computed
Section titled βπ LinkedSignal vs ComputedβThe key difference explained:
// β Computed - Read-only signalconst itemCount = computed(() => this.items().length);// itemCount.set(5); // β Error! Cannot write to computed
// β
LinkedSignal - Writable signalconst itemCount = linkedSignal(() => this.items().length);itemCount.set(5); // β
Works! Can override the derived valueπ Two Syntax Forms
Section titled βπ Two Syntax Formsβ1. Short Form - Simple Derivation
Section titled β1. Short Form - Simple Derivationβ// Simple syntax - like computed but writableconst itemCount = linkedSignal(() => this.items().length);2. Object Form - Advanced Control
Section titled β2. Object Form - Advanced Controlβ// Object syntax - more control over computationconst itemCount = linkedSignal({ source: this.items, computation: (items, previous) => { // Access both current items and previous state return items.length; }});π― Real-World Use Cases
Section titled βπ― Real-World Use CasesβUse Case 1: Volume Control with Default
Section titled βUse Case 1: Volume Control with DefaultβPerfect for audio components that have default volume but allow user overrides:
@Component({ selector: 'app-audio-player', template: ` <div class="audio-player"> <h3>π΅ Music Player</h3>
<div class="controls"> <button (click)="togglePlay()"> {{ isPlaying() ? 'βΈοΈ Pause' : 'βΆοΈ Play' }} </button>
<div class="volume-control"> <label>Volume: {{ currentVolume() }}%</label> <input type="range" min="0" max="100" [value]="currentVolume()" (input)="adjustVolume($event)" /> <button (click)="resetToDefault()">Reset</button> </div> </div> </div> `})export class AudioPlayerComponent { // β
Signal input - parent can set default volume readonly defaultVolume = input(50);
// β
LinkedSignal - starts with default but user can override currentVolume = linkedSignal(() => this.defaultVolume());
isPlaying = signal(false);
adjustVolume(event: Event) { const volume = (event.target as HTMLInputElement).valueAsNumber; this.currentVolume.set(volume); }
resetToDefault() { // Reset to the original default volume this.currentVolume.set(this.defaultVolume()); }
togglePlay() { this.isPlaying.update(playing => !playing); }}
// Usage:// <app-audio-player [defaultVolume]="75" /> <!-- Starts at 75% -->// <app-audio-player [defaultVolume]="25" /> <!-- Starts at 25% -->Use Case 2: Smart Theme Switcher
Section titled βUse Case 2: Smart Theme SwitcherβPerfect for theme management that adapts to system preferences but allows user overrides:
@Component({ selector: 'app-theme-switcher', template: ` <div class="theme-controls"> <h3>π¨ Theme Settings</h3>
<div class="current-theme"> Current Theme: <strong>{{ currentTheme() }}</strong> </div>
<div class="theme-buttons"> <button (click)="setTheme('light')">βοΈ Light</button> <button (click)="setTheme('dark')">π Dark</button> <button (click)="followSystem()">π₯οΈ Follow System</button> </div>
<p class="system-info"> System prefers: {{ systemTheme() }} </p> </div> `})export class ThemeSwitcherComponent implements OnInit { // System theme detection systemTheme = signal<'light' | 'dark'>('light');
// β
LinkedSignal with object syntax for smart theme management currentTheme = linkedSignal<'light' | 'dark', 'light' | 'dark'>({ source: this.systemTheme, computation: (systemTheme, previous) => { // If user has manually set a theme, keep it if (previous?.value && this.userHasOverridden) { return previous.value; } // Otherwise, follow system theme return systemTheme; } });
private userHasOverridden = false;
ngOnInit() { // Detect system theme preference const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); this.systemTheme.set(mediaQuery.matches ? 'dark' : 'light');
// Listen for system theme changes mediaQuery.addEventListener('change', (e) => { this.systemTheme.set(e.matches ? 'dark' : 'light'); }); }
setTheme(theme: 'light' | 'dark') { this.userHasOverridden = true; this.currentTheme.set(theme); }
followSystem() { this.userHasOverridden = false; // Reset to follow system theme this.currentTheme.set(this.systemTheme()); }}π Advanced Patterns
Section titled βπ Advanced PatternsβMultiple Sources - Shopping Cart Total
Section titled βMultiple Sources - Shopping Cart TotalβCombine price, quantity, and discount signals into smart total calculation:
@Component({ selector: 'app-cart-item', template: ` <div class="cart-item"> <h4>{{ productName() }}</h4>
<div class="price-controls"> <label>Price: $</label> <input type="number" [value]="basePrice()" (input)="updatePrice($event)" step="0.01" /> </div>
<div class="quantity-controls"> <label>Quantity:</label> <button (click)="decreaseQty()">-</button> <span>{{ quantity() }}</span> <button (click)="increaseQty()">+</button> </div>
<div class="discount-controls"> <label>Discount %:</label> <input type="number" [value]="discountPercent()" (input)="updateDiscount($event)" min="0" max="100" /> </div>
<div class="totals"> <p>Subtotal: ${{ subtotal().toFixed(2) }}</p> <p>Discount: -${{ discountAmount().toFixed(2) }}</p> <p><strong>Final Total: ${{ finalTotal().toFixed(2) }}</strong></p> <button (click)="applyCustomTotal()">Apply Custom Total</button> </div> </div> `})export class CartItemComponent { productName = signal('Wireless Headphones'); basePrice = signal(99.99); quantity = signal(1); discountPercent = signal(0);
// β
LinkedSignal combining multiple sources finalTotal = linkedSignal< { price: number; qty: number; discount: number }, // Source type number // Return type >({ source: () => ({ price: this.basePrice(), qty: this.quantity(), discount: this.discountPercent() }), computation: (data) => { const subtotal = data.price * data.qty; const discountAmount = subtotal * (data.discount / 100); return subtotal - discountAmount; } });
// Computed helpers for display subtotal = computed(() => this.basePrice() * this.quantity()); discountAmount = computed(() => this.subtotal() * (this.discountPercent() / 100));
updatePrice(event: Event) { const price = (event.target as HTMLInputElement).valueAsNumber || 0; this.basePrice.set(price); }
updateDiscount(event: Event) { const discount = (event.target as HTMLInputElement).valueAsNumber || 0; this.discountPercent.set(Math.max(0, Math.min(100, discount))); }
increaseQty() { this.quantity.update(qty => qty + 1); }
decreaseQty() { this.quantity.update(qty => Math.max(1, qty - 1)); }
applyCustomTotal() { // β
Override the calculated total with a custom value this.finalTotal.set(49.99); }}interface CartItem { id: string; name: string; price: number; quantity: number; category: string;}
@Component({ selector: 'app-smart-cart', template: ` <div class="smart-cart"> <h3>Smart Shopping Cart</h3>
<!-- Cart items --> <div class="cart-items"> @for (item of cartItems(); track item.id) { <div class="cart-item"> <span>{{ item.name }}</span> <span>\${{ item.price }} x {{ item.quantity }}</span> <button (click)="removeItem(item.id)">Remove</button> </div> } </div>
<!-- Automatically calculated totals --> <div class="cart-summary"> <p>Subtotal: \${{ subtotal() }}</p> <p>Tax: \${{ tax() }}</p> <p>Shipping: \${{ shippingCost() }}</p> <h4>Total: \${{ finalTotal() }}</h4> </div>
<!-- Smart recommendations --> <div class="recommendations"> <h4>Smart Suggestions</h4> @for (suggestion of smartSuggestions(); track suggestion) { <p class="suggestion">{{ suggestion }}</p> } </div>
<!-- Loyalty points --> <div class="loyalty"> <p>Points Earned: {{ pointsEarned() }}</p> <p>Loyalty Tier: {{ loyaltyTier() }}</p> </div> </div> `})export class SmartCartComponent { // Source signal - the cart items cartItems = signal<CartItem[]>([ { id: '1', name: 'Laptop', price: 999, quantity: 1, category: 'Electronics' }, { id: '2', name: 'Mouse', price: 25, quantity: 2, category: 'Electronics' } ]);
// Linked signal - automatically calculates subtotal subtotal = linkedSignal(() => { return this.cartItems().reduce((sum, item) => sum + (item.price * item.quantity), 0 ); });
// Linked signal - calculates tax based on subtotal tax = linkedSignal(() => { return Math.round(this.subtotal() * 0.08 * 100) / 100; });
// Linked signal - smart shipping calculation shippingCost = linkedSignal(() => { const subtotal = this.subtotal(); const itemCount = this.cartItems().length;
if (subtotal > 100) return 0; // Free shipping if (itemCount > 3) return 5; // Bulk discount return 10; // Standard shipping });
// Linked signal - final total finalTotal = linkedSignal(() => { return Math.round((this.subtotal() + this.tax() + this.shippingCost()) * 100) / 100; });
// Linked signal - smart suggestions based on cart content smartSuggestions = linkedSignal(() => { const items = this.cartItems(); const categories = new Set(items.map(item => item.category)); const total = this.subtotal(); const suggestions: string[] = [];
if (categories.has('Electronics') && !items.some(item => item.name.includes('Cable'))) { suggestions.push('π± Add cables for your electronics'); }
if (total > 50 && total < 100) { suggestions.push(`π° Add $${(100 - total).toFixed(2)} more for free shipping!`); }
if (items.length === 1) { suggestions.push('ποΈ Bundle items for better deals'); }
return suggestions; });
// Linked signal - loyalty points calculation pointsEarned = linkedSignal(() => { return Math.floor(this.subtotal() / 10); // 1 point per $10 });
// Linked signal - loyalty tier based on points loyaltyTier = linkedSignal(() => { const points = this.pointsEarned(); if (points >= 100) return 'Gold π₯'; if (points >= 50) return 'Silver π₯'; return 'Bronze π₯'; });
removeItem(id: string) { this.cartItems.update(items => items.filter(item => item.id !== id)); // All linked signals automatically recalculate! }
addItem(item: CartItem) { this.cartItems.update(items => [...items, item]); }}β Best Practices
Section titled ββ Best Practicesβ1. Keep Linked Signals Pure
Section titled β1. Keep Linked Signals Pureβ// β
Good - Pure functionconst doubledValue = linkedSignal(() => this.baseValue() * 2);
// β Avoid - Side effectsconst impureSignal = linkedSignal(() => { console.log('Computing...'); // Side effect! return this.baseValue() * 2;});2. Use Meaningful Names
Section titled β2. Use Meaningful Namesβ// β
Good - Descriptive namesconst totalPrice = linkedSignal(() => this.calculateTotal());const isFormValid = linkedSignal(() => this.validateForm());
// β Avoid - Generic namesconst result = linkedSignal(() => this.calculate());const flag = linkedSignal(() => this.check());3. Optimize Complex Calculations
Section titled β3. Optimize Complex Calculationsβ// β
Good - Memoized expensive operationsconst expensiveCalculation = linkedSignal(() => { const data = this.sourceData(); // Only recalculates when sourceData changes return this.performComplexOperation(data);});π― Quick Checklist
Section titled βπ― Quick Checklistβ- Use linked signals for derived state
- Keep linked signal functions pure (no side effects)
- Use descriptive names for linked signals
- Leverage automatic dependency tracking
- Combine with other signals for complex workflows
- Test linked signal behavior thoroughly
- Document complex linked signal relationships
- Use TypeScript for type safety
π Next Steps
Section titled βπ Next Stepsβ- Resource API - Async data handling with signals
- Control Flow - New template syntax patterns
- Zoneless Angular - Performance optimization
Remember: Linked signals are like having a smart assistant that keeps everything in sync automatically. Use them to create reactive, maintainable applications where data flows naturally! π