← Back to Index

Lesson 3

linkedSignal & Dependent State

The Core Idea

Mental model: A 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:

PrimitiveReactive?Writable?Limitation
computed()YesNoCan't be overridden by user interaction
signal()NoYesDoesn'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:

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:

Type parameters: When using the expanded form with 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

Source changes Computation resets value User overrides (.set) 1 2 3 availableCovers list updates selectedCover resets to computed default User picks 'Contents' override holds Next source change resets again

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:

linkedSignal captures the entire "default + smart reset + user override" pattern in a single, declarative expression.

Ad

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.

This is the model() alternative: Angular's 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":

PrimitiveReactive source?Writable?Use when
computed()YesNoValue is always derived - no user override needed
linkedSignal()YesYesValue has a reactive default but can be overridden locally
signal()NoYesValue is independent - no reactive source
Don't reach for linkedSignal when computed will do. If the value should always be derived and never overridden, use 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

  1. Does the value depend on other signals? No - use signal()
  2. Should it always reflect the computation? Yes - use computed()
  3. 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:

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?

linkedSignal is lazy and memoized, computed is not
linkedSignal is writable - you can call .set() on it
linkedSignal runs asynchronously, computed runs synchronously

When the source signal changes, what happens to a linkedSignal's value?

Nothing - it keeps the last value that was .set()
It resets to the result of the computation function
It throws an error because the source is invalid

You have a value that should always be derived from other signals and never overridden. Which primitive?

computed() - read-only derived state
linkedSignal() - writable derived state
signal() - independent writable state

In the expanded form, what does the previous parameter give you access to?

The previous value of the source signal only
Both the previous linkedSignal value (.value) and previous source value (.source)
A history array of all previous values

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:

  1. A writable signal searchQuery (initially empty string)
  2. A linkedSignal for currentPage that resets to 1 whenever searchQuery changes, 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.

  1. A total premium that's always the sum of all line items
  2. A currency dropdown that defaults to the insured's country currency, but the user can override it
  3. A loading flag that gets set to true when a request starts and false when it ends
  4. A "dirty" indicator that turns true when a form field's current value differs from its original value
  5. The selected sort column in a table, which resets to "inception date" when the policy class filter changes
  1. computed() - purely derived, never overridden. computed(() => lineItems().reduce((sum, li) => sum + li.premium, 0))
  2. linkedSignal() - has a reactive default (country currency) but allows user override. Classic linkedSignal pattern.
  3. signal() - independent state with no reactive source. Nothing to derive from - it's set imperatively based on external events (request start/end).
  4. computed() - always derived: computed(() => currentValue() !== originalValue()). No user override needed.
  5. 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.

Something unclear? Ask me follow-up questions - I'm your teacher and can clarify anything, adjust examples, or go deeper on any concept.