Skip to content

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!

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

The key difference explained:

// ❌ Computed - Read-only signal
const itemCount = computed(() => this.items().length);
// itemCount.set(5); // ❌ Error! Cannot write to computed
// βœ… LinkedSignal - Writable signal
const itemCount = linkedSignal(() => this.items().length);
itemCount.set(5); // βœ… Works! Can override the derived value
// Simple syntax - like computed but writable
const itemCount = linkedSignal(() => this.items().length);
// Object syntax - more control over computation
const itemCount = linkedSignal({
source: this.items,
computation: (items, previous) => {
// Access both current items and previous state
return items.length;
}
});

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% -->

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());
}
}

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]);
}
}
// βœ… Good - Pure function
const doubledValue = linkedSignal(() => this.baseValue() * 2);
// ❌ Avoid - Side effects
const impureSignal = linkedSignal(() => {
console.log('Computing...'); // Side effect!
return this.baseValue() * 2;
});
// βœ… Good - Descriptive names
const totalPrice = linkedSignal(() => this.calculateTotal());
const isFormValid = linkedSignal(() => this.validateForm());
// ❌ Avoid - Generic names
const result = linkedSignal(() => this.calculate());
const flag = linkedSignal(() => this.check());
// βœ… Good - Memoized expensive operations
const expensiveCalculation = linkedSignal(() => {
const data = this.sourceData();
// Only recalculates when sourceData changes
return this.performComplexOperation(data);
});
  • 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
  1. Resource API - Async data handling with signals
  2. Control Flow - New template syntax patterns
  3. 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! πŸ”—