Signals are not just “Angular’s version of React hooks.” They’re a fundamentally different reactive primitive, and most of the confusion I see around them comes from treating them as a simpler API for the same thing. They’re not. Once you understand what signals actually guarantee and where they deliberately fall short, everything clicks.
When I led the Angular 17 to 21 migration at Zuub, signals changed how our design system components communicated across 3 apps. We had a shared component library powering a dental SaaS platform, and the moment we started adopting signals, the way state flowed through those components became fundamentally more predictable. This is the deep dive I wish I had when I started that migration.
The Three Primitives
Everything in Angular’s signal system comes down to three building blocks: WritableSignal, computed, and effect. That’s it.
import { signal, computed, effect } from '@angular/core';
// WritableSignal — you own this, you write to it
const count = signal(0);
// computed — derived state, read-only, auto-tracked
const doubled = computed(() => count() * 2);
// effect — side effects that react to signal changes
effect(() => {
console.log(`Count is now ${count()}, doubled is ${doubled()}`);
});
count.set(5);
// Console: "Count is now 5, doubled is 10"
WritableSignal is the only one you write to. computed derives from other signals. effect reacts to changes. If you find yourself doing anything else — calling .set() inside a computed, triggering HTTP calls in an effect — stop. You’re fighting the model.
The Glitch-Free Guarantee
This is the single most important thing to understand about signals, and most tutorials gloss over it.
When you update multiple signals synchronously, any effect or computed that depends on them will only execute once, seeing the final values. Angular batches synchronous updates.
const firstName = signal('Sebastian');
const lastName = signal('Puchet');
const fullName = computed(() => `${firstName()} ${lastName()}`);
effect(() => {
console.log(`Full name: ${fullName()}`);
});
// Both updates happen synchronously
firstName.set('John');
lastName.set('Doe');
// The effect fires ONCE: "Full name: John Doe"
// You never see "John Puchet" — the intermediate state is invisible
This is called the glitch-free property. It prevents your UI from ever rendering an inconsistent intermediate state.
This matters more than you think when you have shared design system components. In our dental SaaS, a patient info header component consumed signals from multiple services — user context, clinic context, active patient. Before signals, you could get a frame where the header showed the right patient but the wrong clinic. Glitch-free updates killed that entire class of bugs without us writing a single line of defensive code.
But here’s the non-obvious consequence: signals are not suited for event streams where intermediate values matter. If you need to react to every single value change — like tracking each keystroke for analytics, or processing a stream of WebSocket messages — signals will swallow intermediate values. That’s RxJS territory. Signals manage state. Observables manage events. Different tools for different problems.
Auto-Tracking: Powerful and Dangerous
Angular tracks every signal read inside a reactive context (computed or effect). This tracking is transitive — it follows function calls.
@Injectable({ providedIn: 'root' })
export class UserService {
private currentUser = signal<User | null>(null);
isAdmin(): boolean {
// This reads a signal internally
return this.currentUser()?.role === 'admin';
}
}
@Component({ /* ... */ })
export class DashboardComponent {
private userService = inject(UserService);
protected readonly showAdminPanel = computed(() => {
// This looks like a plain method call, but it tracks currentUser
return this.userService.isAdmin();
});
}
showAdminPanel will re-evaluate whenever currentUser changes, even though the computed never directly reads that signal. Angular followed the call into isAdmin() and discovered the dependency automatically.
This is powerful. It’s also dangerous. If a service method internally reads signals you don’t know about, your computed will re-evaluate when those signals change. You get invisible dependencies. The fix is to think about this explicitly — which brings us to untracked().
Conditional Tracking and Why It Bites You
Here’s a subtlety that has caught me more than once. If a signal is only read inside a conditional branch, it’s only tracked when that branch executes.
const showDetails = signal(false);
const details = signal('Some details');
const output = computed(() => {
if (showDetails()) {
return `Details: ${details()}`;
}
return 'No details';
});
When showDetails is false, the computed does not track details. Changing details won’t trigger a recomputation. Only once showDetails flips to true and that branch executes does details become a dependency.
The fix is straightforward — read all signals at the top of your reactive context, before any branching:
const output = computed(() => {
const show = showDetails();
const detailText = details(); // Always tracked, regardless of branch
if (show) {
return `Details: ${detailText}`;
}
return 'No details';
});
Now both signals are always tracked. No surprises.
untracked() for Fine-Grained Control
Sometimes you want to read a signal inside a reactive context without creating a dependency on it. That’s what untracked() is for.
import { untracked } from '@angular/core';
const searchQuery = signal('');
const userPreferences = signal({ maxResults: 10 });
const searchConfig = computed(() => {
const query = searchQuery(); // tracked — recompute when query changes
// NOT tracked — preferences changes won't trigger recomputation
const prefs = untracked(() => userPreferences());
return { query, maxResults: prefs.maxResults };
});
This is essential in effects that write to other signals or call services. Without untracked(), you can create accidental feedback loops where an effect triggers itself.
Equality, Immutability, and the Spread Operator
Angular uses reference equality (===) to determine if a signal’s value has changed. For primitives this works perfectly. For objects, it means mutating properties won’t trigger updates.
const user = signal({ name: 'Sebastian', age: 30 });
// This does NOTHING — same reference, Angular sees no change
user().name = 'John';
user.set(user()); // Still the same object reference
// This works — new object reference
user.update(current => ({ ...current, name: 'John' }));
Always use the spread operator for object updates. Mutation is invisible to the signal graph. You can also provide a custom equality function when you need different behavior:
const user = signal({ name: 'Sebastian', age: 30 }, {
equal: (a, b) => a.name === b.name && a.age === b.age
});
The Signal Graph Mental Model
Think of your signals as a directed acyclic graph. Writable signals are the source nodes. Computed signals are intermediate nodes. Effects are the leaf nodes — the terminal points where the signal graph meets the outside world.
[WritableSignal: query] ──→ [computed: filteredResults] ──→ [effect: render]
↑
[WritableSignal: filters] ─────────────┘
Data flows in one direction. Computed signals are pure derivations. Effects are where side effects happen. If you find yourself creating cycles or having effects write back to signals that feed into the same effect, you’ve broken the graph model and need to rethink your data flow.
Pure Helpers Outside the Class
For complex computed logic, I’ve found that extracting pure helper functions outside the component class keeps things clean and testable:
// Pure function — no access to instance state, explicit inputs
function buildFilteredList(items: Item[], query: string, activeOnly: boolean): Item[] {
return items
.filter(item => !activeOnly || item.active)
.filter(item => item.name.toLowerCase().includes(query.toLowerCase()));
}
@Component({ /* ... */ })
export class ItemListComponent {
private itemService = inject(ItemService);
protected readonly query = signal('');
protected readonly activeOnly = signal(false);
protected readonly filteredItems = computed(() =>
buildFilteredList(
this.itemService.items(),
this.query(),
this.activeOnly()
)
);
}
The function can’t accidentally read signals you didn’t intend to track because it has no access to instance state. Every dependency is explicit in the function signature. It’s also trivially unit-testable without any Angular test bed.
Effects Are Last Mile Only
This is the rule I enforce on every project: effects should only handle “last mile” operations. Things that connect the signal world to the non-signal world.
Good uses for effects:
- Painting to a
<canvas>element - Showing toast notifications
- Logging or analytics
- Syncing to
localStorage
Bad uses for effects:
- Business logic
- Calling services
- Updating other signals (unless absolutely necessary and wrapped in
untracked)
// Good — last mile, painting to canvas
effect(() => {
const data = this.chartData();
this.canvasContext.clearRect(0, 0, this.width, this.height);
this.drawChart(this.canvasContext, data);
});
// Good — last mile, logging
effect(() => {
console.log('Active filters:', this.activeFilters());
});
If you’re writing business logic in an effect, it almost certainly belongs in a computed instead.
The protected readonly Pattern
Here is my hot take after migrating a design system with dozens of components across three apps: OnPush + signals should be the non-negotiable default for every shared component. If a component lives in a design system library, it has no business relying on default change detection. Signals give you explicit, trackable reactivity. OnPush ensures nothing re-renders unless a signal actually changed. Together, they make shared components predictable no matter which app consumes them. We enforced this with a lint rule in our Nx workspace and never looked back.
One last thing that has become a strong convention for me: declare component signals as protected readonly.
@Component({ /* ... */ })
export class SearchComponent {
protected readonly query = signal('');
protected readonly results = computed(() => this.filterResults(this.query()));
}
protected because signals exposed to the template don’t need to be public. The template is part of the component — it has protected access. Making them protected prevents other components or services from reaching in and reading or writing your component’s state.
readonly because you never want to replace the signal itself — you call .set() or .update() on it. Without readonly, nothing stops you from accidentally writing this.query = signal('new') instead of this.query.set('new'), which would silently disconnect the old signal from any computed or effect that depended on it.
It’s a small thing. It prevents an entire class of bugs.
Final Thoughts
Signals are not a simplification of RxJS. They’re a different tool with different guarantees. The glitch-free property makes them excellent for state. The auto-tracking makes dependencies implicit and powerful. The rules around conditional tracking and equality make them predictable — once you know the rules.
The mental model is a graph. Data flows down. Effects are at the edges. Keep it clean, keep it directional, and signals will do exactly what you expect.
Next up, I’ll cover httpResource — Angular’s signal-native approach to HTTP data fetching that finally kills manual subscription management for good.