Angular 17+ Signals: Reactive Primitives, Computed, Effects
Angular 17 shipped Signals as a first-class reactive primitive. Here's how signal(), computed(), and effect() actually work - and when to reach for each one.
Why Angular Needed Signals
Zone.js has carried Angular since 2016. It worked - but at a cost. Every async operation, every setTimeout, every event listener got monkey-patched so Angular could detect changes. That worked fine until you started profiling a mid-size dashboard and realized half your frame budget was eaten by change detection you didn't ask for.
Angular 17 made Signals stable (v17.0.0, released November 2023). They're not a React hook or a Vue ref clone - they're Angular's own answer to fine-grained reactivity. The goal is to let Angular skip Zone.js entirely for components that opt in, which translates directly to smaller bundles and faster rendering.
Honestly, the mental model is simpler than what came before. You stop thinking about "when does Angular check this?" and start thinking about "what does this value depend on?" That shift alone reduces a whole class of bugs - the mysterious ExpressionChangedAfterItHasBeenCheckedError goes away when you're working with signals.
Worth noting: you don't have to rewrite everything. Signals interop with RxJS Observables through the toSignal and toObservable helpers. You can adopt them incrementally, component by component.
signal() - The Primitive
signal() creates a writable reactive value. Reading it is a function call - no .value, no subscriptions, just count(). Writing is .set() or .update(). Dead simple.
import { signal } from '@angular/core';
// Create
const count = signal(0);
// Read
console.log(count()); // 0
// Write
count.set(1);
count.update(prev => prev + 1); // 2
// Mutate (for objects/arrays)
const items = signal<string[]>([]);
items.mutate(list => list.push('hello'));The function-call read syntax isn't arbitrary. It lets Angular's compiler track dependencies at the call site - no Proxy magic, no decorators, just function calls that Angular hooks into at compile time. That's how computed signals and effects know what to re-run when something changes.
One more thing - signals are typed. signal<number>(0) gives you a WritableSignal<number>. You'll get a TypeScript error if you try to .set('oops'). In large teams this catches whole categories of runtime bugs before they ever ship.
computed() - Derived State Without the Boilerplate
computed() creates a read-only signal whose value is derived from other signals. It only re-runs when a dependency actually changes - no manual dependency lists, no effect subscriptions.
import { signal, computed } from '@angular/core';
const firstName = signal('Ada');
const lastName = signal('Lovelace');
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // 'Ada Lovelace'
firstName.set('Grace');
console.log(fullName()); // 'Grace Lovelace'In practice, this replaces a lot of ngOnChanges logic and combineLatest pipes you were writing before. If you had a computed value in a component that used to be a getter or a memoized function, computed() slots in with less boilerplate and better caching semantics - it only recomputes when a tracked signal changes.
Look, computed signals are lazy and cached. If nothing reads fullName(), it never runs the derivation. Once read, the result is cached until a dependency changes. So you're not paying for computations that nobody needs - which matters when you're building data-heavy tables or dashboards.
Quick aside: computed() signals can't be written to. If you try, TypeScript won't let you - they return Signal<T> not WritableSignal<T>. This enforces a clean data flow direction that's easy to audit.
effect() - Side Effects Without RxJS
effect() runs a callback whenever any signal it reads changes. Think of it as Angular's version of React's useEffect - but without a dependency array, because Angular tracks dependencies automatically.
import { signal, effect } from '@angular/core';
const theme = signal<'light' | 'dark'>('light');
effect(() => {
document.documentElement.setAttribute('data-theme', theme());
console.log('Theme changed to:', theme());
});
// Later...
theme.set('dark'); // effect runs automaticallyEffects created inside a component's constructor run in the component's injection context and clean themselves up on destroy. You don't have to manually unsubscribe - that's the whole point. Angular ties the effect's lifetime to the component's lifecycle.
@Component({ ... })
export class ThemeToggle {
theme = signal<'light' | 'dark'>('light');
constructor() {
effect(() => {
// Runs whenever this.theme() changes
localStorage.setItem('theme', this.theme());
});
}
toggle() {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
}
}There's a guard you need to know: you can't write to a signal inside an effect() by default. Angular throws if you try, because it prevents infinite loops. If you genuinely need to write inside an effect (rare), pass { allowSignalWrites: true } as the second argument - but treat that as a code smell and look for a computed() solution first.
Signals in Templates - The Real Payoff
This is where the Angular team earns the feature. In a template, you call signals like functions and Angular tracks which signals a template reads. When those signals change, only that component re-renders - no zone-wide detection sweep.
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
`
})
export class Counter {
count = signal(0);
double = computed(() => this.count() * 2);
increment() {
this.count.update(n => n + 1);
}
}With changeDetection: ChangeDetectionStrategy.OnPush (which you should already be using), this component will only check for changes when its signals change. Combine that with a future zoneless setup and you're approaching Vue/SolidJS-style performance from inside Angular.
That said, watch out for mixing signals with async pipe in the same template. You can do it, but it's confusing. When you're in signal-land, lean into toSignal(observable$) and drop the async pipe entirely - cleaner templates, no subscription cleanup to think about.
If you're building UI components with heavy interactivity - think 60px dropdown menus that open on hover, animated cards, live-updating dashboards - this matters. Compare the approach to what you'd build with React state, and you'll appreciate how much less wiring signals need. If you want inspiration for what those UIs can look like, the glassmorphism components gallery is worth a browse.
Interop with RxJS: toSignal and toObservable
You almost certainly have existing code using Observables. Angular didn't throw that away - they gave you bridge functions. toSignal() converts an Observable into a Signal; toObservable() goes the other direction.
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';
// Observable β Signal
const tick = toSignal(interval(1000), { initialValue: 0 });
// tick() returns the latest value, no subscribe needed
// Signal β Observable (for places that still need Observables)
const count = signal(0);
const count$ = toObservable(count);The initialValue option on toSignal is important. Observables are async by nature - they might not emit immediately. Without an initial value, the signal's type becomes T | undefined. Supply an initial value and you get clean T typing throughout.
This interop means you don't have to choose a camp. HTTP calls from HttpClient return Observables - wrap them with toSignal and they land cleanly in your template without an async pipe. It's genuinely one of the cleaner migrations in Angular's recent history.
One practical pattern worth copying: use a loadingState signal alongside toSignal for HTTP requests. Set it to 'loading' before the call, 'success' or 'error' after. Way cleaner than juggling three separate boolean flags.
Signals vs NgRx: When to Use What
This is the question every Angular team is asking right now. Short answer: signals for local and shared-but-simple state, NgRx for complex domain state that needs time-travel debugging, action history, or cross-feature coordination.
Honestly, if you've been reaching for NgRx to manage a sidebar toggle or a selected-tab value, you were already over-engineering it. Those are exactly the cases signals were built for. A signal(false) beats an action + reducer + selector pipeline for a toggle by every measure.
For genuinely complex state - a checkout flow with validation rules, a real-time collaboration feature, a user permissions model that dozens of components consume - NgRx still earns its boilerplate. The Redux DevTools integration, the effects system for side effects, the entity adapters - those solve real problems at scale.
That said, the NgRx team shipped @ngrx/signals (stable in v17) as a signal-based alternative to @ngrx/store. It's lighter, no actions required, and integrates with the Angular signals graph. Worth evaluating if you want structured store patterns without the action/reducer ceremony.
Whatever architecture you pick, keep your UI components thin. Signals in the component, state derivation in services. The Empire UI components you drop in should only consume signals through input bindings - never reach into a store directly from a presentational component. That boundary makes testing and refactoring much less painful.
FAQ
Signals have been stable since Angular 17.0.0, released November 2023. The API is finalized - signal(), computed(), and effect() are safe to use in production. The zoneless rendering mode is still developer preview as of Angular 18.
No. toSignal() and toObservable() from @angular/core/rxjs-interop let them coexist. Migrate incrementally - start with local component state, then move to services.
You can use signal(), computed(), and effect() in any class with an injection context - services, directives, components, and pipes all work. Effects outside a component's constructor need a manual cleanup ref via DestroyRef.
Signals enable component-level granular updates instead of tree-wide zone checks. In benchmarks, signal-based components with OnPush can reduce change detection passes by 80%+ in large applications. The full gain comes when Angular's zoneless mode exits developer preview.