angular-signals
2
总安装量
2
周安装量
#63561
全站排名
安装命令
npx skills add https://github.com/simon-jarillo/prueba-skills --skill angular-signals
Agent 安装分布
claude-code
2
codex
1
github-copilot
1
gemini-cli
1
Skill 文档
Angular Signals
Reactive state management with Angular’s signal primitives for fine-grained reactivity.
Basic Signals
Writable Signals
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">Reset</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
// Update with new value
this.count.set(this.count() + 1);
// Or use update function
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
this.count.set(0);
}
}
Signal with Objects
import { signal } from '@angular/core';
interface User {
id: number;
name: string;
email: string;
}
export class UserComponent {
user = signal<User>({
id: 1,
name: 'John Doe',
email: 'john@example.com'
});
updateName(name: string) {
// Create new object (immutable update)
this.user.update(user => ({
...user,
name
}));
}
updateEmail(email: string) {
this.user.update(user => ({ ...user, email }));
}
}
Signal with Arrays
import { signal } from '@angular/core';
export class TodosComponent {
todos = signal<string[]>([]);
addTodo(text: string) {
this.todos.update(todos => [...todos, text]);
}
removeTodo(index: number) {
this.todos.update(todos =>
todos.filter((_, i) => i !== index)
);
}
clearTodos() {
this.todos.set([]);
}
}
Computed Signals
Basic Computed
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart',
template: `
<p>Items: {{ itemCount() }}</p>
<p>Total: {{ total() | currency }}</p>
<p>Average: {{ average() | currency }}</p>
`
})
export class CartComponent {
items = signal([
{ name: 'Item 1', price: 10.99 },
{ name: 'Item 2', price: 24.99 },
{ name: 'Item 3', price: 15.50 }
]);
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
average = computed(() =>
this.itemCount() > 0 ? this.total() / this.itemCount() : 0
);
}
Chained Computed Signals
import { signal, computed } from '@angular/core';
export class FilterComponent {
items = signal(['Apple', 'Banana', 'Cherry', 'Date']);
searchTerm = signal('');
sortOrder = signal<'asc' | 'desc'>('asc');
// First computed: filter
filteredItems = computed(() => {
const term = this.searchTerm().toLowerCase();
return this.items().filter(item =>
item.toLowerCase().includes(term)
);
});
// Second computed: sort (depends on filteredItems)
sortedItems = computed(() => {
const items = [...this.filteredItems()];
return this.sortOrder() === 'asc'
? items.sort()
: items.sort().reverse();
});
}
Computed with Complex Logic
import { signal, computed } from '@angular/core';
interface Task {
id: number;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
}
export class TasksComponent {
tasks = signal<Task[]>([]);
filter = signal<'all' | 'active' | 'completed'>('all');
filteredTasks = computed(() => {
const tasks = this.tasks();
const filter = this.filter();
switch (filter) {
case 'active':
return tasks.filter(t => !t.completed);
case 'completed':
return tasks.filter(t => t.completed);
default:
return tasks;
}
});
taskStats = computed(() => {
const tasks = this.tasks();
return {
total: tasks.length,
completed: tasks.filter(t => t.completed).length,
active: tasks.filter(t => !t.completed).length,
highPriority: tasks.filter(t => t.priority === 'high').length
};
});
}
Linked Signals (Angular 19+)
Basic Linked Signal
import { Component, signal, linkedSignal } from '@angular/core';
@Component({
selector: 'app-user-editor',
template: `
<input [value]="originalName()" (input)="updateOriginal($event)" />
<input [value]="editedName()" (input)="updateEdited($event)" />
<button (click)="reset()">Reset</button>
`
})
export class UserEditorComponent {
originalName = signal('John Doe');
// editedName is linked to originalName
// When originalName changes, editedName updates
editedName = linkedSignal(() => this.originalName());
updateOriginal(event: Event) {
const input = event.target as HTMLInputElement;
this.originalName.set(input.value);
}
updateEdited(event: Event) {
const input = event.target as HTMLInputElement;
this.editedName.set(input.value);
}
reset() {
// Reset editedName to match originalName
this.editedName.set(this.originalName());
}
}
Linked Signal with Transform
import { signal, linkedSignal } from '@angular/core';
export class TemperatureComponent {
celsius = signal(0);
// Fahrenheit is linked to celsius but transformed
fahrenheit = linkedSignal({
source: () => this.celsius(),
computation: (celsius) => (celsius * 9/5) + 32
});
updateCelsius(value: number) {
this.celsius.set(value);
// fahrenheit automatically updates
}
updateFahrenheit(value: number) {
// Can still set fahrenheit directly
this.fahrenheit.set(value);
// But celsius doesn't update automatically
}
}
Effects
Basic Effect
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-logger',
template: `
<input [value]="searchTerm()" (input)="updateSearch($event)" />
`
})
export class LoggerComponent {
searchTerm = signal('');
constructor() {
// Effect runs whenever searchTerm changes
effect(() => {
console.log('Search term changed:', this.searchTerm());
});
}
updateSearch(event: Event) {
const input = event.target as HTMLInputElement;
this.searchTerm.set(input.value);
}
}
Effect with Cleanup
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-timer',
template: `
<p>{{ count() }}</p>
<button (click)="toggleTimer()">
{{ isRunning() ? 'Stop' : 'Start' }}
</button>
`
})
export class TimerComponent {
count = signal(0);
isRunning = signal(false);
constructor() {
effect((onCleanup) => {
if (this.isRunning()) {
const interval = setInterval(() => {
this.count.update(c => c + 1);
}, 1000);
// Cleanup function
onCleanup(() => {
clearInterval(interval);
});
}
});
}
toggleTimer() {
this.isRunning.update(running => !running);
}
}
Effect with Dependencies
import { effect, signal } from '@angular/core';
export class DataSyncComponent {
userId = signal(1);
refreshFlag = signal(0);
constructor() {
// Effect depends on both userId and refreshFlag
effect(() => {
const id = this.userId();
const flag = this.refreshFlag(); // Track this too
console.log(`Fetching data for user ${id} (refresh: ${flag})`);
// Fetch user data
});
}
refresh() {
// Trigger effect without changing userId
this.refreshFlag.update(f => f + 1);
}
}
RxJS Interop
toSignal – Observable to Signal
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-users',
template: `
@if (users(); as userList) {
@for (user of userList; track user.id) {
<div>{{ user.name }}</div>
}
} @else {
<p>Loading...</p>
}
`
})
export class UsersComponent {
private http = inject(HttpClient);
// Convert Observable to Signal
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] }
);
}
toSignal with Options
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-clock',
template: `<p>{{ time() }}</p>`
})
export class ClockComponent {
time = toSignal(interval(1000), {
initialValue: 0,
requireSync: false, // Don't require synchronous emission
rejectErrors: false // Handle errors gracefully
});
}
toObservable – Signal to Observable
import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-search',
template: `
<input [value]="searchTerm()" (input)="updateSearch($event)" />
`
})
export class SearchComponent {
searchTerm = signal('');
// Convert signal to observable for RxJS operators
searchTerm$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
distinctUntilChanged()
);
constructor() {
this.searchTerm$.subscribe(term => {
console.log('Debounced search:', term);
// Perform search
});
}
updateSearch(event: Event) {
const input = event.target as HTMLInputElement;
this.searchTerm.set(input.value);
}
}
Signal Store Pattern
import { Injectable, signal, computed } from '@angular/core';
interface Todo {
id: number;
text: string;
completed: boolean;
}
@Injectable({ providedIn: 'root' })
export class TodoStore {
// Private state
private todosSignal = signal<Todo[]>([]);
private loadingSignal = signal(false);
private filterSignal = signal<'all' | 'active' | 'completed'>('all');
// Public readonly state
todos = this.todosSignal.asReadonly();
loading = this.loadingSignal.asReadonly();
filter = this.filterSignal.asReadonly();
// Computed values
filteredTodos = computed(() => {
const todos = this.todosSignal();
const filter = this.filterSignal();
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
stats = computed(() => ({
total: this.todosSignal().length,
completed: this.todosSignal().filter(t => t.completed).length,
active: this.todosSignal().filter(t => !t.completed).length
}));
// Actions
addTodo(text: string) {
const newTodo: Todo = {
id: Date.now(),
text,
completed: false
};
this.todosSignal.update(todos => [...todos, newTodo]);
}
toggleTodo(id: number) {
this.todosSignal.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
removeTodo(id: number) {
this.todosSignal.update(todos => todos.filter(t => t.id !== id));
}
setFilter(filter: 'all' | 'active' | 'completed') {
this.filterSignal.set(filter);
}
}
Best Practices
- Use
asReadonly()to expose signals publicly from services - Always update signals immutably – create new objects/arrays
- Prefer computed over effects when deriving values
- Use effects for side effects only (logging, persistence, external APIs)
- Keep signal updates synchronous – no async operations in update()
- Use linkedSignal when you need source tracking with manual overrides
- Convert to observables sparingly – stay in signals when possible
- Batch related updates to avoid unnecessary computations
Common Patterns
Loading State
interface LoadingState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export class DataComponent {
state = signal<LoadingState<User[]>>({
data: null,
loading: false,
error: null
});
async loadData() {
this.state.update(s => ({ ...s, loading: true, error: null }));
try {
const data = await fetchUsers();
this.state.update(s => ({ ...s, data, loading: false }));
} catch (error) {
this.state.update(s => ({
...s,
error: error.message,
loading: false
}));
}
}
}
Pagination
export class PaginatedListComponent {
items = signal<Item[]>([]);
page = signal(1);
pageSize = signal(10);
paginatedItems = computed(() => {
const start = (this.page() - 1) * this.pageSize();
const end = start + this.pageSize();
return this.items().slice(start, end);
});
totalPages = computed(() =>
Math.ceil(this.items().length / this.pageSize())
);
nextPage() {
this.page.update(p => Math.min(p + 1, this.totalPages()));
}
previousPage() {
this.page.update(p => Math.max(p - 1, 1));
}
}
Resources
- Angular Signals Guide: https://angular.dev/guide/signals
- RxJS Interop: https://angular.dev/guide/signals/rxjs-interop