Skip to main content

Angular Signals Fundamentals

What Are Signals?

Signals are Angular's new reactive primitive (stable in Angular 17) that provide fine-grained reactivity for managing state and computed values.

Core Concepts

1. Creating Signals with signal()

import { Component, signal } from "@angular/core";

@Component({
selector: "app-counter",
standalone: true,
template: `
<div>
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
</div>
`,
})
export class CounterComponent {
// Create a writable signal
count = signal(0);

increment() {
// Update signal value
this.count.set(this.count() + 1);

// Or use update method
// this.count.update(value => value + 1);
}
}

Key Points:

  • Call signal as a function to read: count()
  • Use .set() to replace value
  • Use .update() to transform value

2. Computed Signals with computed()

Derived values that automatically update when dependencies change:

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

@Component({
selector: 'app-shopping-cart',
standalone: true,
template: `
<div>
<p>Items: {{ itemCount() }}</p>
<p>Total: ${{ total() }}</p>
<p>Tax: ${{ tax() }}</p>
<p>Grand Total: ${{ grandTotal() }}</p>
</div>
`
})
export class ShoppingCartComponent {
items = signal([
{ name: 'Product 1', price: 29.99, quantity: 2 },
{ name: 'Product 2', price: 49.99, quantity: 1 }
]);

itemCount = computed(() => this.items().length);

total = computed(() =>
this.items().reduce((sum, item) =>
sum + (item.price * item.quantity), 0
)
);

tax = computed(() => this.total() * 0.08);

grandTotal = computed(() => this.total() + this.tax());
}

Computed Benefits:

  • Automatic dependency tracking
  • Only recalculates when dependencies change
  • Memoized (cached) values
  • Always synchronous

3. Side Effects with effect()

Run code when signal dependencies change:

import { Component, signal, effect } from "@angular/core";

@Component({
selector: "app-user-preferences",
standalone: true,
template: `
<select [(ngModel)]="theme" (change)="updateTheme($event)">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
`,
})
export class UserPreferencesComponent {
theme = signal<"light" | "dark">("light");

constructor() {
// Effect runs when theme signal changes
effect(() => {
console.log("Theme changed to:", this.theme());

// Side effect: update localStorage
localStorage.setItem("theme", this.theme());

// Side effect: update document class
document.body.className = this.theme();
});
}

updateTheme(event: Event) {
const select = event.target as HTMLSelectElement;
this.theme.set(select.value as "light" | "dark");
}
}

Effect Guidelines:

  • Use for side effects (logging, localStorage, DOM manipulation)
  • Don't use for deriving values (use computed() instead)
  • Runs at least once immediately
  • Automatically tracks dependencies

Signal Methods

Writable Signal Methods

const count = signal(0);

// Read value
console.log(count()); // 0

// Set new value
count.set(10);

// Update based on current value
count.update((current) => current + 1);

// Mutate objects/arrays (Angular 17.1+)
const items = signal([1, 2, 3]);
items.mutate((arr) => arr.push(4)); // Efficient for large arrays

Read-Only Signals

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

const count = signal(0);
const doubled = computed(() => count() * 2);

// doubled is read-only
count.set(5);
console.log(doubled()); // 10

// This would error:
// doubled.set(20); ❌ Error: doubled is read-only

Practical Example: Todo List

import { Component, signal, computed } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";

interface Todo {
id: number;
text: string;
completed: boolean;
}

@Component({
selector: "app-todo-list",
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="todo-app">
<h1>Todo List ({{ stats().total }} items)</h1>

<input [(ngModel)]="newTodoText" (keyup.enter)="addTodo()" placeholder="Add a todo..." />

<div class="stats">
<span>Completed: {{ stats().completed }}</span>
<span>Remaining: {{ stats().remaining }}</span>
</div>

<ul>
@for (todo of todos(); track todo.id) {
<li [class.completed]="todo.completed">
<input type="checkbox" [checked]="todo.completed" (change)="toggleTodo(todo.id)" />
<span>{{ todo.text }}</span>
<button (click)="deleteTodo(todo.id)">Delete</button>
</li>
}
</ul>
</div>
`,
styles: [
`
.completed span {
text-decoration: line-through;
}
`,
],
})
export class TodoListComponent {
// State
todos = signal<Todo[]>([]);
newTodoText = "";

// Computed values
stats = computed(() => {
const todos = this.todos();
return {
total: todos.length,
completed: todos.filter((t) => t.completed).length,
remaining: todos.filter((t) => !t.completed).length,
};
});

// Actions
addTodo() {
if (!this.newTodoText.trim()) return;

this.todos.update((todos) => [
...todos,
{
id: Date.now(),
text: this.newTodoText,
completed: false,
},
]);

this.newTodoText = "";
}

toggleTodo(id: number) {
this.todos.update((todos) =>
todos.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
}

deleteTodo(id: number) {
this.todos.update((todos) => todos.filter((t) => t.id !== id));
}
}

Best Practices

✅ Do

// Clear signal names
const userName = signal("John");
const isLoading = signal(false);

// Use computed for derived values
const fullName = computed(() => `${firstName()} ${lastName()}`);

// Use update for transformations
count.update((n) => n + 1);

❌ Don't

// Don't use effects for derived values
effect(() => {
const doubled = count() * 2; // ❌ Use computed instead
});

// Don't create signals in templates
// ❌ Bad: {{ signal(value) }}

// Don't forget to call signal as function
// ❌ Bad: count (returns signal object)
// ✅ Good: count() (returns value)

Performance Benefits

  • Fine-grained updates: Only affected components re-render
  • Automatic optimization: No manual change detection tuning
  • Better than Zone.js: Direct dependency tracking
  • Zoneless ready: Works without Zone.js overhead

Interview Questions

Q: How do signals differ from RxJS observables? A: Signals are synchronous, always have a current value, and use pull-based reactivity. Observables are asynchronous, push-based, and better for streams.

Q: When should you use computed() vs effect()? A: Use computed() for derived values. Use effect() for side effects like logging, localStorage, or DOM manipulation.

Q: Are signals replacing RxJS? A: No, they complement each other. Use signals for local state, RxJS for async streams and complex operations.