Lesson 3
linkedSignal & Dependent State
The Core Idea
linkedSignal is a writable signal that automatically resets its value based on a reactive source. Think of it as a computed() that you can also .set() manually. When the source changes, the linked signal snaps back to its computed default. When the user (or your code) overrides it, the override holds until the source changes again.
The Problem linkedSignal Solves
Before linkedSignal, you had two options for derived state:
| Primitive | Reactive? | Writable? | Limitation |
|---|---|---|---|
computed() | Yes | No | Can't be overridden by user interaction |
signal() | No | Yes | Doesn't react to source changes |
The gap: what about state that usually derives from a source, but sometimes gets overridden by the user? Classic example: a dropdown defaults to the first option, but the user can select a different one. When the options list changes, the selection should reset to a valid default.
Without linkedSignal, people reached for effect() to propagate state - which we learned in Lesson 2 is an antipattern. linkedSignal is the correct tool for this.
The Function Form (Shorthand)
The simplest form of linkedSignal takes a computation function - identical syntax to computed(), but returns a WritableSignal:
import { signal, linkedSignal } from '@angular/core';
const coverTypes = signal(['Buildings', 'Contents', 'Business Interruption']);
// Defaults to the first cover type, resets when coverTypes changes
const selectedCover = linkedSignal(() => coverTypes()[0]);
console.log(selectedCover()); // 'Buildings'
// User selects a different option
selectedCover.set('Contents');
console.log(selectedCover()); // 'Contents'
// Source changes - linkedSignal resets to new computation result
coverTypes.set(['Motor Fleet', 'Cargo', 'Marine Hull']);
console.log(selectedCover()); // 'Motor Fleet'
In a real component, that .set() call lives in the event handler wired to a template control:
@Component({
selector: 'app-cover-picker',
template: `
<label for="cover-select">Cover type</label>
<select id="cover-select" [value]="selectedCover()" (change)="onCoverChange($event)">
@for (cover of coverTypes(); track cover) {
<option [value]="cover">{{ cover }}</option>
}
</select>
<p>Selected: {{ selectedCover() }}</p>
`,
})
export class CoverPickerComponent {
// Could come from a service, a store, or an input()
readonly coverTypes = signal(['Buildings', 'Contents', 'Business Interruption']);
// Defaults to first, resets when the list changes
readonly selectedCover = linkedSignal(() => this.coverTypes()[0]);
onCoverChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.selectedCover.set(value); // user override - holds until coverTypes changes
}
}
When coverTypes changes later - maybe a parent pushes new options via an input(), or a service emits a new list - the linkedSignal resets to coverTypes()[0] automatically. No cleanup logic, no subscription management.
Key behaviours:
- Writable - you can call
.set()and.update()on it, just like a regular signal. - Reactive - when any signal read inside the computation changes, the linked signal resets to the computation result.
- Overridable - manual writes are preserved until the next source change triggers a reset.
The RxJS Comparison
// RxJS way - BehaviorSubject that resets on source emission
private coverTypes$ = new BehaviorSubject(['Buildings', 'Contents', 'BI']);
private userSelection$ = new BehaviorSubject<string | null>(null);
selectedCover$ = this.coverTypes$.pipe(
switchMap(types => this.userSelection$.pipe(
startWith(null),
map(selection => selection ?? types[0])
))
);
// Plus: manual reset logic when coverTypes$ emits, unsubscription, etc.
// Signal way - one line
const selectedCover = linkedSignal(() => this.coverTypes()[0]);
The RxJS version requires orchestrating two subjects, a switchMap, and careful thought about when to reset the user selection. The linkedSignal captures the entire pattern in a single declaration.
The Expanded Form (Object): Previous Value Access
The function form above always resets to whatever the computation returns - it has no memory of the previous value. But what if you want smarter behaviour - like preserving the user's selection if it's still valid in the new list?
The expanded form passes an object with source and computation instead of a bare function. The computation receives the previous value as its second argument, letting you make intelligent decisions about what to keep:
interface CoverType {
id: string;
name: string;
}
const availableCovers = signal<CoverType[]>([
{ id: 'buildings', name: 'Buildings' },
{ id: 'contents', name: 'Contents' },
{ id: 'bi', name: 'Business Interruption' },
]);
const selectedCover = linkedSignal<CoverType[], CoverType>({
source: availableCovers,
computation: (newCovers, previous) => {
// If the previously selected cover still exists, keep it
const preserved = newCovers.find(c => c.id === previous?.value.id);
return preserved ?? newCovers[0];
},
});
The computation function receives:
- First argument - the new value of
source. - Second argument - a
previousobject with.value(previous linkedSignal value) and.source(previous source value). This isundefinedon the first computation.
previous, you must provide explicit generic types: linkedSignal<SourceType, OutputType>({...}). The first type is the source signal's value type, the second is the linkedSignal's output type.
How It Works: The Reset Cycle
The lifecycle of a linkedSignal follows a predictable cycle:
linkedSignal Reset Cycle
The cycle repeats: source change triggers reset, user override holds until the next source change.
Real-World Pattern: Policy Dashboard Selection
In our Policy Dashboard, the user selects a policy from a filtered list. When filters change, the selection should reset to the first result - unless the currently selected policy is still in the new results:
@Component({ /* ... */ })
export class PolicyListComponent {
private readonly policyService = inject(PolicyService);
// Inputs that affect filtering
readonly classOfBusiness = input<string>('all');
readonly status = input<PolicyStatus>('active');
// Filtered policies - derived from inputs
readonly filteredPolicies = computed(() =>
this.policyService.policies()
.filter(p => this.classOfBusiness() === 'all' || p.class === this.classOfBusiness())
.filter(p => p.status === this.status())
);
// Selected policy - resets smartly when filtered list changes
readonly selectedPolicy = linkedSignal<Policy[], Policy | null>({
source: this.filteredPolicies,
computation: (policies, previous) => {
if (!policies.length) return null;
// Preserve selection if it's still in the filtered results
const preserved = policies.find(p => p.id === previous?.value?.id);
return preserved ?? policies[0];
},
});
selectPolicy(policy: Policy): void {
this.selectedPolicy.set(policy); // user override
}
}
Without linkedSignal, this pattern would require either:
- An
effect()that sets a separate signal (antipattern - state propagation via effect) - Manual reset logic scattered across filter change handlers
- A
BehaviorSubject+switchMap+startWithorchestration
linkedSignal captures the entire "default + smart reset + user override" pattern in a single, declarative expression.
linkedSignal with Component Inputs
One of the most common use cases: a component receives an input that provides a default, but the user can modify it locally. When the parent sends a new input, the local state should reset:
@Component({
selector: 'app-premium-calculator',
template: `
<label>
Excess amount
<input type="number" [value]="excessAmount()" (input)="updateExcess($event)" />
</label>
<p>Current excess: {{ excessAmount() | currency }}</p>
`
})
export class PremiumCalculator {
// Parent provides a default excess based on the policy class
readonly defaultExcess = input.required<number>();
// Local state: follows the input, but user can override
readonly excessAmount = linkedSignal(() => this.defaultExcess());
updateExcess(event: Event): void {
const value = +(event.target as HTMLInputElement).value;
this.excessAmount.set(value);
}
}
When the parent changes the defaultExcess input (e.g. the user switches to a different policy class), the excessAmount resets to the new default. If the user has typed a custom value, it's preserved until the next input change.
model() primitive (stable since Angular 18) creates a WritableSignal that syncs both directions - when the child writes to it, the parent sees the change automatically via [(value)] two-way binding. linkedSignal + input() creates one-way-in with local override - the parent pushes a default in, the child can override it locally, but the parent never sees the user's changes unless you add an explicit output(). Choose based on whether the parent needs to know about local modifications. We'll cover model() in detail in Lesson 15 when building custom form controls.
Custom Equality
Like signal() and computed() (see Custom Equality Functions in Lesson 1), you can provide an equal option to linkedSignal. This controls when downstream consumers consider the value "changed":
const activePolicy = signal<Policy>(initialPolicy);
// Only consider it "changed" if the policy ID is different
const editableCopy = linkedSignal(() => activePolicy(), {
equal: (a, b) => a.id === b.id,
});
// Or with the expanded form:
const editableCopy = linkedSignal({
source: activePolicy,
computation: (policy) => ({ ...policy }),
equal: (a, b) => a.id === b.id,
});
This prevents unnecessary re-renders when the source emits a new object with the same logical identity.
When to Use Which
The signal primitives form a spectrum from "fully derived" to "fully independent":
| Primitive | Reactive source? | Writable? | Use when |
|---|---|---|---|
computed() | Yes | No | Value is always derived - no user override needed |
linkedSignal() | Yes | Yes | Value has a reactive default but can be overridden locally |
signal() | No | Yes | Value is independent - no reactive source |
computed(). linkedSignal is for the specific pattern of "reactive default + local override." Using it where computed suffices adds unnecessary writability and makes the code harder to reason about.
Decision tree
- Does the value depend on other signals? No - use
signal() - Should it always reflect the computation? Yes - use
computed() - Should it reflect the computation unless overridden? Yes - use
linkedSignal()
The RxJS Comparison
There's no single RxJS operator that does what linkedSignal does. The closest pattern combines multiple concepts:
// RxJS way - resettable selection state
private source$ = this.store.select(selectFilteredPolicies);
private userOverride$ = new Subject<Policy>();
private reset$ = this.source$.pipe(map(policies => policies[0]));
selectedPolicy$ = merge(this.reset$, this.userOverride$).pipe(
distinctUntilChanged((a, b) => a?.id === b?.id),
shareReplay(1)
);
selectPolicy(policy: Policy) {
this.userOverride$.next(policy);
}
// Plus: unsubscription, timing issues if override races with reset
// Signal way
selectedPolicy = linkedSignal<Policy[], Policy | null>({
source: this.filteredPolicies,
computation: (policies, prev) =>
policies.find(p => p.id === prev?.value?.id) ?? policies[0],
});
The RxJS pattern requires merging two streams, managing race conditions between the reset and override, and careful subscription management. The linkedSignal is synchronous, glitch-free, and declarative.
Common Patterns
Form field with resettable default
// Inception date defaults to today, resets when policy type changes
readonly policyType = input.required<string>();
readonly inceptionDate = linkedSignal(() => {
// Different policy types have different default inception rules
const type = this.policyType();
return type === 'renewal' ? this.currentPolicy().expiryDate : new Date();
});
Tab selection that preserves across safe transitions
readonly availableTabs = computed(() =>
this.userRole() === 'underwriter'
? ['summary', 'risk', 'pricing', 'claims']
: ['summary', 'claims']
);
readonly activeTab = linkedSignal<string[], string>({
source: this.availableTabs,
computation: (tabs, previous) =>
// Keep current tab if it's still available after role change
tabs.includes(previous?.value ?? '') ? previous!.value : tabs[0],
});
Pagination that resets on filter change
readonly searchQuery = signal('');
readonly currentPage = linkedSignal(() => {
this.searchQuery(); // track the dependency
return 1; // always reset to page 1 when search changes
});
When Classic NgRx Would Have Done This
In a classic NgRx setup, this "dependent selection" pattern typically involved:
- An action dispatched when filters change
- A reducer that resets the selected item
- A selector that derives the "effective selection" from store state
- A separate action + reducer for user selection
That's four moving parts (two actions, a reducer case, a selector) for what linkedSignal captures in one declaration. This is why NgRx SignalStore (Lesson 5+) uses signals directly rather than the action/reducer cycle for most local state patterns.
Quiz
What makes linkedSignal different from computed?
When the source signal changes, what happens to a linkedSignal's value?
You have a value that should always be derived from other signals and never overridden. Which primitive?
In the expanded form, what does the previous parameter give you access to?
Try It Yourself
Exercise 1: Pagination Reset
You're building a claims list with search and pagination. When the user types a new search query, the page should reset to 1. But the user can also manually navigate to any page. Write the signals:
- A writable signal
searchQuery(initially empty string) - A
linkedSignalforcurrentPagethat resets to 1 wheneversearchQuerychanges, but allows manual override via.set()
What form of linkedSignal do you need - shorthand or expanded? Why?
import { signal, linkedSignal } from '@angular/core';
const searchQuery = signal('');
const currentPage = linkedSignal(() => {
searchQuery(); // track the dependency
return 1; // always reset to page 1
});
// User navigates to page 3
currentPage.set(3);
console.log(currentPage()); // 3
// User types a new search - resets to 1
searchQuery.set('flood damage');
console.log(currentPage()); // 1
The shorthand form is sufficient here because you always want to reset to the same value (1) regardless of what was there before. You don't need the previous value.
Exercise 2: Smart Tab Preservation
A component shows tabs based on user role. When the role changes, the active tab should stay the same if it's still available, otherwise fall back to the first tab. Write this using the expanded form:
const userRole = signal<'broker' | 'underwriter' | 'claims'>('broker');
const availableTabs = computed(() => {
switch (userRole()) {
case 'broker': return ['summary', 'documents', 'endorsements'];
case 'underwriter': return ['summary', 'risk', 'pricing', 'documents'];
case 'claims': return ['summary', 'reserves', 'payments'];
}
});
// TODO: Write the linkedSignal for activeTab
// - Should preserve the current tab if it exists in the new tab list
// - Should fall back to the first available tab otherwise
const activeTab = linkedSignal<string[], string>({
source: availableTabs,
computation: (tabs, previous) => {
// If the previous tab is still available, keep it
if (previous && tabs.includes(previous.value)) {
return previous.value;
}
// Otherwise fall back to first tab
return tabs[0];
},
});
// Initially: 'summary' (first tab for broker)
activeTab.set('documents');
console.log(activeTab()); // 'documents'
// Role changes to underwriter - 'documents' still available
userRole.set('underwriter');
console.log(activeTab()); // 'documents' (preserved!)
// Role changes to claims - 'documents' not available, falls back
userRole.set('claims');
console.log(activeTab()); // 'summary' (fallback to first)
The expanded form is needed because we access previous.value to check if the old tab is still valid. The type parameters <string[], string> tell TypeScript that the source is a string array and the output is a single string.
Exercise 3: Choose the Right Primitive
For each scenario, decide: signal(), computed(), or linkedSignal()? Explain why.
- A total premium that's always the sum of all line items
- A currency dropdown that defaults to the insured's country currency, but the user can override it
- A loading flag that gets set to
truewhen a request starts andfalsewhen it ends - A "dirty" indicator that turns true when a form field's current value differs from its original value
- The selected sort column in a table, which resets to "inception date" when the policy class filter changes
computed()- purely derived, never overridden.computed(() => lineItems().reduce((sum, li) => sum + li.premium, 0))linkedSignal()- has a reactive default (country currency) but allows user override. Classic linkedSignal pattern.signal()- independent state with no reactive source. Nothing to derive from - it's set imperatively based on external events (request start/end).computed()- always derived:computed(() => currentValue() !== originalValue()). No user override needed.linkedSignal()- resets to a default when a source changes, but allows manual override.linkedSignal(() => { classFilter(); return 'inceptionDate'; })
Your Tangible Win
You now have the complete signal primitives toolkit: signal() for independent state, computed() for read-only derived state, and linkedSignal() for the "reactive default with local override" pattern. Together with effect() for side effects, these four primitives cover every reactive state scenario that previously required complex RxJS orchestration or NgRx action/reducer ceremonies.
Next lesson, we'll bridge the gap between these signal primitives and your existing RxJS code with toSignal() and toObservable() - the interop layer that lets you adopt signals incrementally without rewriting everything at once.