DEV Community

Cover image for Angular Signals in 2026: From Fundamentals to Architect-Level Patterns
Amanulla Khan
Amanulla Khan

Posted on

Angular Signals in 2026: From Fundamentals to Architect-Level Patterns

Angular used to be the framework you apologized for. Too much boilerplate, too much Zone.js magic, too much RxJS ceremony for a checkbox. Somewhere between v16 and v22, that changed. Signals are no longer "a new primitive to learn" — as of Angular 22 (June 2026), OnPush is the default change detection strategy, zoneless is the default for new apps, and Signal Forms and the Resource API are stable. Angular is now, by design, a signal-first framework.

This article covers the full arc: what signals are, how they work internally, how they change your architecture and performance story, how they compare to RxJS, and how to actually migrate a real codebase — plus the interview questions you'll get asked about all of it.

1. The fundamentals

A signal is a wrapper around a value that knows who's reading it.

import { signal, computed, effect } from '@angular/core';

const count = signal(0);          // writable signal
const doubled = computed(() => count() * 2); // derived, read-only, memoized

effect(() => {
  console.log(`count is now ${count()}`); // reruns when count changes
});

count.set(5);
count.update(v => v + 1);
Enter fullscreen mode Exit fullscreen mode

Three primitives, three jobs:

  • signal() — holds state, read by calling it as a function, written via .set() / .update().
  • computed() — derives state. Lazy and memoized: it only recalculates when a dependency actually changed and someone reads it.
  • effect() — runs side effects (logging, syncing to localStorage, imperative DOM work) in response to signal changes. Not for driving template state — if you're setting another signal inside an effect to keep it "in sync," you probably wanted computed() or linkedSignal().

Angular 20 graduated all of these — plus signal-based input(), model(), and signal queries (viewChild, contentChild) — to stable. If you're on 20+, this is no longer "the new way," it's the way for new components.

@Component({ selector: 'app-user-card', standalone: true, template: `...` })
export class UserCardComponent {
  userId = input.required<string>();       // signal-based @Input
  isActive = model(false);                 // two-way bindable signal
  header = viewChild<ElementRef>('header'); // signal-based @ViewChild
}
Enter fullscreen mode Exit fullscreen mode

2. How signals actually work (the internals interviewers ask about)

Signals implement a push-pull reactive model, and this distinction is the single most-tested concept in interviews:

  • Push: when a writable signal changes, it immediately (synchronously) notifies its direct consumers that they're "dirty." This is cheap — it's just a flag flip, not a recomputation.
  • Pull: the actual recomputation only happens lazily, when something reads the dirty signal — inside a computed() or during change detection when a template reads it.

This is why signals are glitch-free. In a naive push-based system, if computed C depends on both A and B, and you update A then B in the same tick, C might recompute twice and briefly observe an inconsistent intermediate state. Angular's dependency graph batches this: C is marked dirty once, and only recomputes once, on next read, with both updates applied.

Under the hood, each signal maintains a version counter. A computed() caches its last value and the versions of the signals it read last time. On read, it compares versions; if nothing upstream changed, it returns the cached value without re-executing the function — that's the memoization.

Dependency tracking is automatic and dynamic. There's no subscribe/unsubscribe. computed() and effect() track whatever signals were actually read during their last execution — so a conditional read (isAdmin() ? adminData() : userData()) only creates a dependency on the branch that actually executed. Change the condition, and the dependency set changes on the next run. This is structurally similar to Vue's reactivity system and SolidJS, and it's a fair comparison to bring up in an interview.

3. Change detection implications (this is the part senior candidates get wrong)

Pre-signals Angular used Zone.js to monkey-patch async APIs (setTimeout, addEventListener, promises) so it could guess something might have changed and run change detection on the whole component tree from the root. It works, but it's a blunt instrument — you get checks you don't need, and debugging "why did CD run" is miserable.

Signals flip this to explicit, targeted notification. A component reading a signal in its template gets automatically marked for a targeted check when that signal changes — no zone patching, no tree-wide guessing.

As of Angular 22, OnPush is the default changeDetection strategy for new components (the old "Default" strategy is renamed Eager and deprecated). Combined with zoneless (stable default since Angular 21), the practical effect is:

  • No Zone.js in your bundle (smaller payload, faster startup).
  • Change detection runs only for components whose signals actually changed, not the whole tree.
  • markForCheck() calls scattered through legacy code become mostly unnecessary — signals do it for you.
  • Non-signal mutations (mutating a plain array/object field that CD can't observe) silently stop working correctly under zoneless. This is the #1 zoneless migration bug — reaching into this.items.push(x) on a plain field instead of this.items.update(arr => [...arr, x]).

Interview question you should be able to answer cold: "Why does OnPush + signals outperform Zone.js-based default CD, and what's the one thing that breaks silently when you go zoneless?" Answer: targeted vs. tree-wide checks, and the answer to the second half is exactly the mutation issue above.

4. linkedSignal and resource() — the pieces that make signals a full state system

Two APIs turn signals from "a better @Input" into an actual state-management layer.

linkedSignal (stable since Angular 19) is a writable signal that resets itself when a source signal changes — the classic "selected item resets when the list changes" problem, without an effect() fighting your computed():

const items = signal(['a', 'b', 'c']);
const selected = linkedSignal(() => items()[0]); // resets to items()[0] whenever items() changes
selected.set('b'); // but user can still override it locally until items() changes again
Enter fullscreen mode Exit fullscreen mode

resource() / httpResource() (stable in Angular 22) replace the "subscribe in ngOnInit, manage loading/error flags by hand" pattern with a declarative async primitive:

userId = input.required<string>();

userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
// userResource.value(), userResource.status(), userResource.error(), userResource.isLoading()
Enter fullscreen mode Exit fullscreen mode

Change userId, and the resource automatically re-fetches — no manual switchMap, no subscription teardown. As of Angular 21.2, snapshot() lets you derive a new resource from an existing one (e.g., a filtered/paginated view) without touching the original fetch logic — genuinely useful for enterprise dashboards with shared base data and multiple derived views.

5. Signals vs. RxJS — pick the right tool, not a religion

Signals RxJS
Best for Synchronous UI state, derived values, template bindings Streams over time: websockets, typeahead debounce, complex event composition
Mental model Pull-based, glitch-free, always has a current value Push-based, operators, may never emit
Boilerplate Minimal — no subscribe/unsubscribe Higher — operators, takeUntilDestroyed, memory-leak discipline
Composability Growing (linkedSignal, resource) but narrower Extremely rich (60+ operators)
Angular's stance (2026) Default for component state Still first-class for genuine async streams

The Angular team has been explicit: RxJS isn't going away. toSignal() / toObservable() interop exists precisely because you'll keep both in the same codebase — RxJS for a websocket-driven live feed, signals for the UI state that renders it. Treat "should I use signals or RxJS" the way you'd treat "should I use a Map or an array" — depends on the shape of the problem, not on which one is newer.

6. Migrating a real codebase (NgModules + RxJS → Signals)

Don't do a big-bang rewrite. The Angular team's own guidance, and what holds up in practice on large enterprise apps:

  1. Upgrade to a supported version first. Combining a multi-version jump with a signals migration is the most common way these efforts stall out — get current, then modernize.
  2. Convert leaf components first. Presentational components with @Input()/@Output() are low-risk: swap to input()/output(), verify, move up the tree.
  3. Replace BehaviorSubject-as-state with signal(). If a BehaviorSubject exists purely to hold "current value UI reads," it's a signal in disguise.
  4. Keep genuine streams as Observables, bridge with toSignal() at the template boundary.
  5. Turn on OnPush explicitly before you touch signals, so you isolate "did this break because of CD strategy" from "did this break because of signals."
  6. Zoneless last. It's the biggest behavioral change (silent mutation bugs) — do it once signals are already load-bearing in the app.

7. Enterprise example: a filtered order dashboard

@Component({
  selector: 'app-order-dashboard',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)" />
    @if (ordersResource.isLoading()) { <app-spinner /> }
    @for (order of filteredOrders(); track order.id) {
      <app-order-row [order]="order" />
    }
  `,
})
export class OrderDashboardComponent {
  customerId = input.required<string>();
  statusFilter = signal<'all' | 'pending' | 'shipped'>('all');

  ordersResource = httpResource<Order[]>(
    () => `/api/customers/${this.customerId()}/orders`
  );

  filteredOrders = computed(() => {
    const orders = this.ordersResource.value() ?? [];
    const status = this.statusFilter();
    return status === 'all' ? orders : orders.filter(o => o.status === status);
  });

  constructor() {
    effect(() => {
      // side effect only — analytics, not state
      analytics.track('dashboard_filtered', { status: this.statusFilter() });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what's absent: no ngOnInit subscription, no loading/error boolean fields, no manual unsubscribe, no markForCheck(). Changing customerId() re-fetches automatically; changing statusFilter() re-filters automatically; both are independently cached and only recompute what actually needs it.

8. Common anti-patterns

  • Effects that write signals to "sync" state. If effect() exists only to call .set() on another signal, use computed() (or linkedSignal() if it needs local override capability) instead.
  • Reading a signal outside a reactive context and expecting reactivity. Calling count() in a regular method gives you a snapshot, not a subscription — reactivity only happens inside computed, effect, or a template.
  • Mutating instead of replacing (array.push() vs .update(a => [...a, x])) — silently breaks under zoneless/OnPush since the reference never changes.
  • Overusing effect() for cross-component communication instead of a shared injectable signal/service — reintroduces the debugging pain signals were meant to remove.

9. Interview questions to be ready for

  • Explain the push-pull model and why it makes signals glitch-free.
  • What's the practical difference between computed() and effect(), and when would using one instead of the other cause a bug?
  • Why does OnPush change detection pair naturally with signals, and what breaks when you combine plain-object mutation with zoneless CD?
  • How does dynamic dependency tracking work, and what happens to a computed's dependency list when a conditional branch changes?
  • When would you still reach for RxJS over signals in a signals-first Angular 22 app?
  • What does linkedSignal solve that a plain computed() can't?

10. Challenge exercise

Build a typeahead search component using httpResource() and linkedSignal(): a signal<string>() for the raw query, a computed() or linkedSignal() for a debounced/trimmed version (hint: you'll actually want RxJS's debounceTime here via toSignal(toObservable(query).pipe(debounceTime(300))) — a good exercise in knowing exactly where the signals/RxJS boundary should sit), and a resource() that re-fetches on the debounced value. Add a linkedSignal for "selected result" that resets whenever the result list changes.

Closing thought

Signals aren't a syntax swap for @Input() — they're Angular's answer to "how do we know exactly what changed, without guessing." That answer now shapes change detection defaults, forms, async data fetching, and DI. If you're prepping for a senior/architect role, the framework-trivia questions ("what's the syntax for computed") are table stakes; the questions that actually separate candidates are about the reactive graph — push vs. pull, dependency tracking, and where the signals/RxJS boundary should sit in a real system.

Top comments (0)