home / skills / analogjs / angular-skills / angular-signals

angular-signals skill

/skills/angular-signals

This skill helps Angular developers adopt signals for reactive state management, enabling writable, derived, and dependent state with effects.

npx playbooks add skill analogjs/angular-skills --skill angular-signals

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
7.5 KB
---
name: angular-signals
description: Implement signal-based reactive state management in Angular v20+. Use for creating reactive state with signal(), derived state with computed(), dependent state with linkedSignal(), and side effects with effect(). Triggers on state management questions, converting from BehaviorSubject/Observable patterns to signals, or implementing reactive data flows.
---

# Angular Signals

Signals are Angular's reactive primitive for state management. They provide synchronous, fine-grained reactivity.

## Core Signal APIs

### signal() - Writable State

```typescript
import { signal } from '@angular/core';

// Create writable signal
const count = signal(0);

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

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

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

// With explicit type
const user = signal<User | null>(null);
user.set({ id: 1, name: 'Alice' });
```

### computed() - Derived State

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

const firstName = signal('John');
const lastName = signal('Doe');

// Derived signal - automatically updates when dependencies change
const fullName = computed(() => `${firstName()} ${lastName()}`);

console.log(fullName()); // "John Doe"
firstName.set('Jane');
console.log(fullName()); // "Jane Doe"

// Computed with complex logic
const items = signal<Item[]>([]);
const filter = signal('');

const filteredItems = computed(() => {
  const query = filter().toLowerCase();
  return items().filter(item => 
    item.name.toLowerCase().includes(query)
  );
});

const totalPrice = computed(() => 
  filteredItems().reduce((sum, item) => sum + item.price, 0)
);
```

### linkedSignal() - Dependent State with Reset

```typescript
import { signal, linkedSignal } from '@angular/core';

const options = signal(['A', 'B', 'C']);

// Resets to first option when options change
const selected = linkedSignal(() => options()[0]);

console.log(selected()); // "A"
selected.set('B');       // User selects B
console.log(selected()); // "B"
options.set(['X', 'Y']); // Options change
console.log(selected()); // "X" - auto-reset to first

// With previous value access
const items = signal<Item[]>([]);

const selectedItem = linkedSignal<Item[], Item | null>({
  source: () => items(),
  computation: (newItems, previous) => {
    // Try to preserve selection if item still exists
    const prevItem = previous?.value;
    if (prevItem && newItems.some(i => i.id === prevItem.id)) {
      return prevItem;
    }
    return newItems[0] ?? null;
  },
});
```

### effect() - Side Effects

```typescript
import { signal, effect, inject, DestroyRef } from '@angular/core';

@Component({...})
export class SearchComponent {
  query = signal('');
  
  constructor() {
    // Effect runs when query changes
    effect(() => {
      console.log('Search query:', this.query());
    });
    
    // Effect with cleanup
    effect((onCleanup) => {
      const timer = setInterval(() => {
        console.log('Current query:', this.query());
      }, 1000);
      
      onCleanup(() => clearInterval(timer));
    });
  }
}
```

**Effect rules:**
- Cannot write to signals by default (use `allowSignalWrites` if needed)
- Run in injection context (constructor or with `runInInjectionContext`)
- Automatically cleaned up when component destroys

```typescript
// Writing signals in effects (use sparingly)
effect(() => {
  if (this.query().length > 0) {
    this.hasSearched.set(true);
  }
}, { allowSignalWrites: true });
```

## Component State Pattern

```typescript
@Component({
  selector: 'app-todo-list',
  template: `
    <input [value]="newTodo()" (input)="newTodo.set($any($event.target).value)" />
    <button (click)="addTodo()" [disabled]="!canAdd()">Add</button>
    
    <ul>
      @for (todo of filteredTodos(); track todo.id) {
        <li [class.done]="todo.done">
          {{ todo.text }}
          <button (click)="toggleTodo(todo.id)">Toggle</button>
        </li>
      }
    </ul>
    
    <p>{{ remaining() }} remaining</p>
  `,
})
export class TodoListComponent {
  // State
  todos = signal<Todo[]>([]);
  newTodo = signal('');
  filter = signal<'all' | 'active' | 'done'>('all');
  
  // Derived state
  canAdd = computed(() => this.newTodo().trim().length > 0);
  
  filteredTodos = computed(() => {
    const todos = this.todos();
    switch (this.filter()) {
      case 'active': return todos.filter(t => !t.done);
      case 'done': return todos.filter(t => t.done);
      default: return todos;
    }
  });
  
  remaining = computed(() => 
    this.todos().filter(t => !t.done).length
  );
  
  // Actions
  addTodo() {
    const text = this.newTodo().trim();
    if (text) {
      this.todos.update(todos => [
        ...todos,
        { id: crypto.randomUUID(), text, done: false }
      ]);
      this.newTodo.set('');
    }
  }
  
  toggleTodo(id: string) {
    this.todos.update(todos =>
      todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
    );
  }
}
```

## RxJS Interop

### toSignal() - Observable to Signal

```typescript
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({...})
export class TimerComponent {
  private http = inject(HttpClient);
  
  // From observable - requires initial value or allowUndefined
  counter = toSignal(interval(1000), { initialValue: 0 });
  
  // From HTTP - undefined until loaded
  users = toSignal(this.http.get<User[]>('/api/users'));
  
  // With requireSync for synchronous observables (BehaviorSubject)
  private user$ = new BehaviorSubject<User | null>(null);
  currentUser = toSignal(this.user$, { requireSync: true });
}
```

### toObservable() - Signal to Observable

```typescript
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime } from 'rxjs';

@Component({...})
export class SearchComponent {
  query = signal('');
  
  private http = inject(HttpClient);
  
  // Convert signal to observable for RxJS operators
  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`))
    ),
    { initialValue: [] }
  );
}
```

## Signal Equality

```typescript
// Custom equality function
const user = signal<User>(
  { id: 1, name: 'Alice' },
  { equal: (a, b) => a.id === b.id }
);

// Only triggers updates when ID changes
user.set({ id: 1, name: 'Alice Updated' }); // No update
user.set({ id: 2, name: 'Bob' }); // Triggers update
```

## Untracked Reads

```typescript
import { untracked } from '@angular/core';

const a = signal(1);
const b = signal(2);

// Only depends on 'a', not 'b'
const result = computed(() => {
  const aVal = a();
  const bVal = untracked(() => b());
  return aVal + bVal;
});
```

## Service State Pattern

```typescript
@Injectable({ providedIn: 'root' })
export class AuthService {
  // Private writable state
  private _user = signal<User | null>(null);
  private _loading = signal(false);
  
  // Public read-only signals
  readonly user = this._user.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly isAuthenticated = computed(() => this._user() !== null);
  
  private http = inject(HttpClient);
  
  async login(credentials: Credentials): Promise<void> {
    this._loading.set(true);
    try {
      const user = await firstValueFrom(
        this.http.post<User>('/api/login', credentials)
      );
      this._user.set(user);
    } finally {
      this._loading.set(false);
    }
  }
  
  logout(): void {
    this._user.set(null);
  }
}
```

For advanced patterns including resource(), see [references/signal-patterns.md](references/signal-patterns.md).

Overview

This skill implements signal-based reactive state management for Angular v20+. It teaches creating writable signals with signal(), derived values with computed(), dependent resets with linkedSignal(), and side effects with effect(). The skill focuses on converting BehaviorSubject/Observable patterns to signals and building predictable, synchronous state flows.

How this skill works

The skill inspects component and service state patterns and shows how to replace RxJS-centric state with Angular signals. It demonstrates creating signals, composing computed and linkedSignal dependents, and using effect for side effects and cleanup. It also covers RxJS interop with toSignal and toObservable plus equality, untracked reads, and readonly state for services.

When to use it

  • Migrating components that rely on BehaviorSubject/Observables to signal-based state.
  • Building local component state with synchronous, fine-grained reactivity.
  • Creating derived, memoized values that update automatically when dependencies change.
  • Implementing dependent selections that reset when their source changes (linkedSignal).
  • Converting observable data flows where you still need RxJS operators (use toObservable/toSignal).

Best practices

  • Keep writable signals private in services and expose readonly signals via asReadonly().
  • Use computed() for derived state and avoid duplicating logic across components.
  • Use linkedSignal() when a child selection should reset on source updates, and preserve previous selection when appropriate.
  • Keep effects focused: avoid writing signals inside effects unless using allowSignalWrites intentionally.
  • Use toSignal() with initialValue or requireSync for BehaviorSubjects to avoid undefined values.

Example use cases

  • Replace a BehaviorSubject-based counter with a writable signal and computed selectors.
  • Implement a todo list with signal-backed todos, computed filteredTodos, and remaining count.
  • Create a selection UI that auto-resets when the options source changes using linkedSignal().
  • Convert a search input debounce pipeline: toObservable(query) -> debounceTime -> switchMap -> toSignal(results).
  • Manage auth state in a service with private signals and public readonly computed isAuthenticated.

FAQ

Can effects write to signals?

By default effects cannot write to signals. Use the allowSignalWrites option sparingly when you need an effect to update other signals.

How do I convert a BehaviorSubject to a signal without losing the current value?

Use toSignal(behaviorSubject, { requireSync: true }) to create a signal that immediately reflects the BehaviorSubject's current value.

When should I use linkedSignal vs computed?

Use computed for derived values that always follow dependencies. Use linkedSignal when you need a dependent value that can be set independently but should reset or recompute when its source changes.