angular-signals

📁 simon-jarillo/prueba-skills 📅 Jan 26, 2026
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

  1. Use asReadonly() to expose signals publicly from services
  2. Always update signals immutably – create new objects/arrays
  3. Prefer computed over effects when deriving values
  4. Use effects for side effects only (logging, persistence, external APIs)
  5. Keep signal updates synchronous – no async operations in update()
  6. Use linkedSignal when you need source tracking with manual overrides
  7. Convert to observables sparingly – stay in signals when possible
  8. 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