TL;DR: Angular signals offer a simpler, more performant alternative to RxJS for managing reactive state in Angular apps. This guide explores how to use writable, computed, and linked signals to build scalable, reactive components in 2025.
Angular signals are transforming how developers manage state in Angular apps. If you’ve struggled with RxJS complexity or performance bottlenecks, signals offer a cleaner, more intuitive solution. In this guide, you’ll learn how to use signals to build reactive, scalable components in 2025 and beyond.
Think of Angular signals as smart containers for your application state that automatically notify interested parties when their values change. Unlike traditional reactive patterns, signals provide a synchronous, pull-based reactivity model that’s both intuitive and performant.
At its core, a signal is a wrapper around a value that tracks where it’s being used throughout your application. This granular tracking allows Angular to optimize rendering updates with surgical precision, updating only the components and DOM elements that depend on the changed data.
import { signal } from "@angular/core";
// Create a signal with an initial value
const count = signal(0);
// Read the signal's value by calling it as a function
console.log("Current count:", count()); // 0
// Update the signal's value
count.set(5);
count.update((current) => current + 1); The beauty of signals lies in their simplicity: they’re just getter functions that Angular can track automatically.
Signals solve critical challenges that have plagued Angular development for years:
Angular’s signal system has three fundamental building blocks that work together to create a complete reactive programming model.
Writable signals are the foundation of your reactive state management:
import { signal, WritableSignal } from "@angular/core";
@Component({
template: `
<div>
<p>User: {{ user().name }}</p>
<p>Email: {{ user().email }}</p>
<button (click)="updateUser()">Update User</button>
</div>
`,
})
export class UserComponent {
user: WritableSignal<User> = signal({
name: "John Doe",
email: "john@example.com",
});
updateUser() {
this.user.update((current) => ({
...current,
email: "john.doe@newdomain.com",
}));
}
}
interface User {
name: string;
email: string;
} Computed signals automatically derive their values from other signals, creating a reactive dependency graph:
import { computed, signal } from "@angular/core";
import { FormsModule } from '@angular/forms';
@Component({
template: `
<div>
<input [(ngModel)]="firstName" />
<input [(ngModel)]="lastName" />
<h2>Welcome, {{ fullName() }}!</h2>
<p>Initials: {{ initials() }}</p>
</div>
`,
})
export class NameComponent {
firstName = signal("");
lastName = signal("");
// Automatically updates when firstName or lastName changes
fullName = computed(() => `${this.firstName()} ${this.lastName()}`.trim());
initials = computed(() =>
`${this.firstName().charAt(0)}${this.lastName().charAt(0)}`.toUpperCase());
} Effects run side effects in response to signal changes, perfect for logging, persistence, or DOM manipulation:
import { effect, signal } from "@angular/core";
@Component({})
export class DataPersistenceComponent {
userData = signal({ preferences: "dark-theme" });
constructor() {
// Automatically sync to localStorage when userData changes
effect(() => {
const data = this.userData();
localStorage.setItem("userData", JSON.stringify(data));
console.log("User data saved:", data);
});
}
} Start by importing the necessary functions from @angular/core. These imports provide everything you need to implement reactive patterns in your components:
import { signal, computed, effect, WritableSignal } from "@angular/core"; Here’s a complete counter component that demonstrates the reactive nature of signals and how they automatically update the UI:
import { Component, signal, computed } from "@angular/core";
@Component({
selector: "app-counter",
standalone: true,
template: `
<div class="counter-container">
<h2>Count: {{ count() }}</h2>
<p>Double: {{ doubleCount() }}</p>
<p>Status: {{ status() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`,
})
export class CounterComponent {
count = signal(0);
doubleCount = computed(() => this.count() * 2);
status = computed(() => {
const value = this.count();
if (value === 0) return "neutral";
return value > 0 ? "positive" : "negative";
});
increment() {
this.count.update((value) => value + 1);
}
decrement() {
this.count.update((value) => value - 1);
}
reset() {
this.count.set(0);
}
} The count signal holds the main state, while doubleCount and status are computed signals that automatically derive their values from count. When you click any button to change the count value, Angular instantly updates all dependent computed signals and re-renders only the affected parts of the template. Notice how you access signal values in the template by calling them as functions {{ count() }}, and how the computed signals automatically recalculate without any manual coordination. This demonstrates the core power of signals: declarative state management with automatic reactivity.
Angular signals offer sophisticated patterns for managing intricate state relationships and asynchronous operations as your application grows in complexity.
The new linkedSignal function solves a common problem: managing state that should automatically update based on other state changes, while still allowing manual user interactions. It’s perfect for dropdown selections that need to be reset when options change.
import { signal, linkedSignal } from "@angular/core";
@Component({})
export class ShippingComponent {
shippingOptions = signal([
{ id: 1, name: "Standard" },
{ id: 2, name: "Express" },
{ id: 3, name: "Overnight" },
]);
// Automatically resets to the first option when shipping options change
// but allows manual selection
selectedOption = linkedSignal(() => this.shippingOptions()[0]);
selectOption(option: ShippingOption) {
this.selectedOption.set(option);
}
}
interface ShippingOption {
id: number;
name: string;
} Unlike computed signals (which are read-only), linkedSignal creates a writable signal that automatically updates when its source changes. In this example, selectedOption will always default to the first shipping option whenever shippingOptions changes, but users can still manually select different options using the selectOption method. This prevents the common UI bug where a user’s selection becomes invalid after data updates, while preserving user choice when possible.
The experimental resource function brings async operations into the signals world, providing a declarative way to handle data fetching that automatically reacts to signal changes. This powerful feature bridges the gap between synchronous signals and asynchronous operations like HTTP requests.
import { resource, signal } from "@angular/core";
@Component({})
export class UserProfileComponent {
userId = signal("123");
userResource = resource({
params: () => ({ id: this.userId() }),
loader: ({ params, abortSignal }) =>
fetch(`/api/users/${params.id}`, { signal: abortSignal }).then(
(response) => response.json()
),
});
// Reactive computed based on resource state
userName = computed(() => {
if (this.userResource.hasValue()) {
return this.userResource.value().name;
}
return "Loading...";
});
} In this example, the resource function creates a reactive data loader that:
userId signal changes, thanks to the paramsabortSignal when parameters change mid-flight.isLoading, hasValue, and status.The resource object exposes several useful properties: value contains the loaded data, isLoading indicates request status, hasValue() acts as a type guard, and error captures any failures. This makes it incredibly easy to build robust UIs that handle loading states, errors, and data updates automatically.
// ❌ Wrong: Side effect in computed
const badComputed = computed(() => {
const data = this.data();
this.logService.log(data); // Side effect!
return data.processed;
});
// ✅ Correct: Use effect for side effects
const goodComputed = computed(() => this.data().processed);
effect(() => {
this.logService.log(this.data()); // Side effect in effect
}); The key mistake above is mixing side effects (like logging) with computed signal logic. Computed signals should be pure functions that only transform input values and return results; they shouldn’t perform actions like logging, API calls, or DOM updates. When you include side effects in computed signals, Angular may call them multiple times during optimization, causing unwanted duplicate logs or operations. The correct approach separates these concerns: use computed signals purely for data transformation (goodComputed only processes the data), while effects handle all side effects like logging. This separation ensures predictable behavior and better performance.
| Use signals for | Use RxJS for |
| Managing synchronous state | Handling complex async operations |
| Building reactive UI components | Needing time-based operators (debounce, throttle) |
| Optimizing change detection performance | Managing HTTP requests with retry logic. |
| Working with simple data flows | Working with event streams |
Sometimes you need the best of both worlds: Signals for simple reactivity and RxJS for complex async operations. Angular provides seamless interoperability between these two systems through conversion functions.
import { Component, inject, signal } from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { debounceTime, distinctUntilChanged, switchMap } from "rxjs";
@Component({})
export class SearchComponent {
searchTerm = signal("");
private searchService = inject(SearchService); // A dummy service.
// Convert signal to observable for RxJS operators
searchResults = toSignal(
toObservable(this.searchTerm).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((term) => this.searchService.search(term))
)
);
onSearch(term: string) {
this.searchTerm.set(term);
}
} This example demonstrates a reactive search feature where user input triggers API calls with intelligent throttling. The toObservable() function converts the searchTerm signal into an RxJS observable, allowing us to use powerful operators like debounceTime (waits 300ms after the user stops typing), distinctUntilChanged (prevents duplicate requests), and switchMap (cancels previous requests when new ones arrive). Finally, toSignal() converts the result into a signal for easy template consumption. This hybrid approach gives you signal simplicity for UI state and RxJS power for complex async operations.
This example demonstrates how signals create a seamless reactive theme system that automatically adapts to user preferences and system settings. The theme component showcases the power of computed signals to derive state from multiple sources while maintaining clean, predictable logic.
import { Component, computed, signal } from "@angular/core";
@Component({
template: `
<div [class]="themeClass()">
<select (change)="setTheme($event)">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
<p>Current theme: {{ currentTheme() }}</p>
</div>
`,
})
export class ThemeComponent {
selectedTheme = signal<"light" | "dark" | "auto">("auto");
systemTheme = signal<"light" | "dark">("light");
currentTheme = computed(() => {
const selected = this.selectedTheme();
return selected === "auto" ? this.systemTheme() : selected;
});
themeClass = computed(() => `theme-${this.currentTheme()}`);
constructor() {
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.systemTheme.set(mediaQuery.matches ? "dark" : "light");
mediaQuery.addEventListener("change", (e) => {
this.systemTheme.set(e.matches ? "dark" : "light");
});
}
setTheme(event: Event) {
const target = event.target as HTMLSelectElement;
this.selectedTheme.set(target.value as any);
}
} selectedTheme and systemTheme) hold the core state. The selectedTheme tracks user preference while systemTheme reflects the OS color scheme preference.currentTheme computed signal determines which theme to use. When the user selects auto, it defers to the system theme; otherwise, it uses their explicit choice. This computation runs automatically whenever either source signal changes.themeClass computed signal transforms the current theme into a CSS class name (like theme-dark or theme-light), creating clean separation between theme logic and styling.mediaQuery listener that automatically updates the systemTheme signal when the user changes their OS dark mode preference, creating a reactive connection between system settings and your application.This declarative approach lets you define a theme based on various inputs, while the signals system handles all reactive updates without manual subscription management or complex state synchronization.
This example showcases how signals excel at managing complex form state with real-time validation. By leveraging computed signals, we create a reactive validation system that automatically updates as users type, providing instant feedback without manual event handling or complex state synchronization.
import { Component, computed, signal } from "@angular/core";
@Component({})
export class ContactFormComponent {
formData = signal<ContactFormData>({
name: "",
email: "",
message: "",
});
// Validation signals
isNameValid = computed(() => this.formData().name.trim().length >= 2);
isEmailValid = computed(() =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData().email)
);
isMessageValid = computed(() => this.formData().message.trim().length >= 10);
// Form state signals
isFormValid = computed(
() => this.isNameValid() && this.isEmailValid() && this.isMessageValid()
);
formErrors = computed(() => ({
name: !this.isNameValid() ? "Name must be at least 2 characters" : "",
email: !this.isEmailValid() ? "Please enter a valid email" : "",
message: !this.isMessageValid() ? "Message must be at least 10 characters" : "",
}));
updateField(field: keyof ContactFormData, value: string) {
this.formData.update((current) => ({
...current,
[field]: value,
}));
}
}
interface ContactFormData {
name: string;
email: string;
message: string;
} formData signal acts as a single source of truth for all form values, eliminating the need to track individual form controls separately and ensuring consistency across the component.isNameValid, isEmailValid, isMessageValid). These automatically re-evaluate whenever formData changes, providing real-time validation feedback without manual triggers.isFormValid computed signal combines all individual validations using logical AND. This creates a master validation state that updates automatically when any field’s validity changes, perfect for enabling/disabling submit buttons.formErrors computed signal generates user-friendly error messages based on validation state. It returns empty strings for valid fields and descriptive messages for invalid ones, making it easy to display conditional error UI.updateField method uses the signal’s update function with immutable patterns to modify form data. This ensures Angular’s change detection can track modifications efficiently while maintaining predictable state transitions.ContactFormData interface provides compile-time safety for field names and types, preventing typos and ensuring consistency.This transforms form handling from an imperative, event-driven process into a declarative, reactive system that’s more maintainable and performant.
Angular Signals are more than just a new feature; they’re a paradigm shift in building reactive Angular apps. By mastering writable, computed, and linked signals, you can simplify your codebase, improve performance, and future-proof your applications.
Here are the key points to remember:
As you embark on your signals journey, start small: convert a simple component to use signals, then gradually apply these patterns to larger parts of your application. Angular Signals are your gateway to building more responsive, maintainable, and performant web applications.
Ready to take your Angular skills to the next level? Explore how Syncfusion® Angular components integrate seamlessly with signals for even more power. If you require any assistance, please don’t hesitate to contact us via our support forum. We are always eager to help you!