angular-signals
$
npx mdskill add TheBushidoCollective/han/angular-signalsBuild reactive Angular apps with fine-grained state management.
- Enables zone-less change detection for improved performance.
- Integrates with Angular core APIs like signal, computed, and effect.
- Executes code based on reactive dependencies and signal updates.
- Generates TypeScript code for components and signal logic.
SKILL.md
.github/skills/angular-signalsView on GitHub ↗
---
name: angular-signals
user-invocable: false
description: Use when building Angular 16+ applications requiring fine-grained reactive state management and zone-less change detection.
allowed-tools:
- Bash
- Read
---
# Angular Signals
Master Angular Signals for building reactive applications with
fine-grained reactivity and improved performance.
## Signal Basics
### Creating and Using Signals
```typescript
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Writable signal
count = signal(0);
// Computed signal
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect runs when count changes
effect(() => {
console.log(`Count is: ${this.count()}`);
});
}
increment() {
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
this.count.set(0);
}
}
```
### Signal Methods
```typescript
import { signal } from '@angular/core';
// Create signal
const count = signal(0);
// set - replace value
count.set(5);
// update - transform current value
count.update(value => value + 1);
// mutate - modify object (experimental)
const user = signal({ name: 'John', age: 30 });
user.mutate(value => {
value.age = 31; // Mutate in place
});
// Read value
const current = count(); // Call as function
```
## Computed Signals
### Basic Computed
```typescript
import { signal, computed } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
// Computed signal
const fullName = computed(() => {
return `${firstName()} ${lastName()}`;
});
console.log(fullName()); // John Doe
firstName.set('Jane');
console.log(fullName()); // Jane Doe (automatically updates)
```
### Complex Computed
```typescript
interface Product {
id: number;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-cart'
})
export class CartComponent {
items = signal<Product[]>([]);
// Computed: total items
itemCount = computed(() => {
return this.items().reduce((sum, item) => sum + item.quantity, 0);
});
// Computed: subtotal
subtotal = computed(() => {
return this.items().reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
});
// Computed: tax
tax = computed(() => this.subtotal() * 0.08);
// Computed: total
total = computed(() => this.subtotal() + this.tax());
// Computed: formatted total
formattedTotal = computed(() => {
return `$${this.total().toFixed(2)}`;
});
}
```
### Chained Computed
```typescript
const count = signal(1);
const doubled = computed(() => count() * 2);
const quadrupled = computed(() => doubled() * 2);
const formatted = computed(() => `Count: ${quadrupled()}`);
console.log(formatted()); // Count: 4
count.set(2);
console.log(formatted()); // Count: 8
```
## Effects
### Basic Effects
```typescript
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-logger'
})
export class LoggerComponent {
count = signal(0);
constructor() {
// Effect runs when count changes
effect(() => {
console.log(`Count changed to: ${this.count()}`);
});
}
increment() {
this.count.update(v => v + 1); // Triggers effect
}
}
```
### Effect Cleanup
```typescript
import { effect } from '@angular/core';
const count = signal(0);
effect((onCleanup) => {
const timer = setInterval(() => {
console.log(count());
}, 1000);
// Cleanup function
onCleanup(() => {
clearInterval(timer);
});
});
```
### Conditional Effects
```typescript
import { effect, signal } from '@angular/core';
const enabled = signal(true);
const count = signal(0);
effect(() => {
// Only run if enabled
if (!enabled()) return;
console.log(`Count: ${count()}`);
});
```
## Signal Inputs
### Component Inputs as Signals
```typescript
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-user-profile',
template: `
<div>
<h2>{{ displayName() }}</h2>
<p>Age: {{ age() }}</p>
<p>Is adult: {{ isAdult() }}</p>
</div>
`
})
export class UserProfileComponent {
// Signal inputs (Angular 17.1+)
firstName = input.required<string>();
lastName = input.required<string>();
age = input(0); // Optional with default
// Computed from inputs
displayName = computed(() =>
`${this.firstName()} ${this.lastName()}`
);
isAdult = computed(() => this.age() >= 18);
}
// Usage
<app-user-profile
[firstName]="'John'"
[lastName]="'Doe'"
[age]="30"
/>
```
### Transform Input Signals
```typescript
import { Component, input } from '@angular/core';
@Component({
selector: 'app-formatted-text'
})
export class FormattedTextComponent {
// Transform input
text = input('', {
transform: (value: string) => value.toUpperCase()
});
// Alias input
label = input('', { alias: 'labelText' });
}
// Usage
<app-formatted-text
[text]="'hello'"
[labelText]="'Name'"
/>
```
## Signal Outputs
### Component Outputs as Signals
```typescript
import { Component, output } from '@angular/core';
@Component({
selector: 'app-button',
template: `
<button (click)="handleClick()">
{{ label() }}
</button>
`
})
export class ButtonComponent {
label = input('Click me');
// Signal output (Angular 17.1+)
clicked = output<void>();
valueChanged = output<number>();
private clickCount = signal(0);
handleClick() {
this.clickCount.update(v => v + 1);
this.clicked.emit();
this.valueChanged.emit(this.clickCount());
}
}
// Usage
<app-button
(clicked)="onClicked()"
(valueChanged)="onValueChanged($event)"
/>
```
## Signal Queries
### ViewChild with Signals
```typescript
import { Component, viewChild, ElementRef, afterNextRender } from '@angular/core';
@Component({
selector: 'app-input-focus',
template: `
<input #inputElement type="text" />
<button (click)="focusInput()">Focus</button>
`
})
export class InputFocusComponent {
// Signal-based viewChild
inputElement = viewChild<ElementRef>('inputElement');
constructor() {
afterNextRender(() => {
// Access element after render
const element = this.inputElement()?.nativeElement;
if (element) {
element.focus();
}
});
}
focusInput() {
this.inputElement()?.nativeElement.focus();
}
}
```
### ViewChildren with Signals
```typescript
import { Component, viewChildren, ElementRef } from '@angular/core';
@Component({
selector: 'app-list',
template: `
<div #item *ngFor="let item of items()">
{{ item }}
</div>
<p>Item count: {{ itemElements().length }}</p>
`
})
export class ListComponent {
items = signal(['A', 'B', 'C']);
// Signal-based viewChildren
itemElements = viewChildren<ElementRef>('item');
logItemCount() {
console.log(`Count: ${this.itemElements().length}`);
}
}
```
### ContentChild with Signals
```typescript
import { Component, contentChild, Directive } from '@angular/core';
@Directive({
selector: '[appHeader]'
})
export class HeaderDirective {}
@Component({
selector: 'app-card',
template: `
<div class="card">
<ng-content select="[appHeader]" />
<ng-content />
<p *ngIf="hasHeader()">Has custom header</p>
</div>
`
})
export class CardComponent {
// Signal-based contentChild
header = contentChild(HeaderDirective);
hasHeader = computed(() => !!this.header());
}
// Usage
<app-card>
<h2 appHeader>Title</h2>
<p>Content</p>
</app-card>
```
## Signals vs Observables
### When to Use Signals
```typescript
// Use signals for synchronous state
@Component({
selector: 'app-counter'
})
export class CounterComponent {
count = signal(0); // Signal for synchronous state
increment() {
this.count.update(v => v + 1);
}
}
```
### When to Use Observables
```typescript
// Use observables for async operations
@Component({
selector: 'app-user-list'
})
export class UserListComponent {
private http = inject(HttpClient);
users$: Observable<User[]> = this.http.get<User[]>('/api/users');
}
```
### Combining Signals and Observables
```typescript
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs/operators';
@Component({
selector: 'app-search'
})
export class SearchComponent {
private http = inject(HttpClient);
// Signal for search query
searchQuery = signal('');
// Convert signal to observable
searchQuery$ = toObservable(this.searchQuery);
// Use observable operators
results$ = this.searchQuery$.pipe(
debounceTime(300),
switchMap(query => this.http.get(`/api/search?q=${query}`))
);
// Convert back to signal
results = toSignal(this.results$, { initialValue: [] });
}
```
## toSignal and toObservable
### toSignal - Observable to Signal
```typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="users()">
<div *ngFor="let user of users()">
{{ user.name }}
</div>
</div>
`
})
export class UserListComponent {
private http = inject(HttpClient);
// Convert observable to signal
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] as User[] }
);
}
```
### toObservable - Signal to Observable
```typescript
import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs/operators';
@Component({
selector: 'app-search'
})
export class SearchComponent {
searchTerm = signal('');
// Convert signal to observable
searchTerm$ = toObservable(this.searchTerm);
constructor() {
// Use observable operators
this.searchTerm$.pipe(
debounceTime(300)
).subscribe(term => {
console.log('Searching for:', term);
});
}
}
```
## Signal Equality and Change Detection
### Custom Equality Function
```typescript
import { signal } from '@angular/core';
interface User {
id: number;
name: string;
}
// Custom equality check
const user = signal<User>(
{ id: 1, name: 'John' },
{
equal: (a, b) => a.id === b.id // Only compare IDs
}
);
user.set({ id: 1, name: 'Jane' }); // No update (same ID)
user.set({ id: 2, name: 'John' }); // Updates (different ID)
```
### Zone-less Change Detection
```typescript
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
@Component({
selector: 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
// Signal updates trigger change detection automatically
this.count.update(v => v + 1);
}
}
```
## Migration from Observables
### Before - Observables
```typescript
import { Component } from '@angular/core';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-cart'
})
export class CartComponentOld {
private items$ = new BehaviorSubject<Product[]>([]);
private discount$ = new BehaviorSubject<number>(0);
total$ = combineLatest([this.items$, this.discount$]).pipe(
map(([items, discount]) => {
const subtotal = items.reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
return subtotal * (1 - discount);
})
);
addItem(item: Product) {
this.items$.next([...this.items$.value, item]);
}
}
```
### After - Signals
```typescript
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart'
})
export class CartComponent {
items = signal<Product[]>([]);
discount = signal(0);
total = computed(() => {
const subtotal = this.items().reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
return subtotal * (1 - this.discount());
});
addItem(item: Product) {
this.items.update(items => [...items, item]);
}
}
```
## When to Use This Skill
Use angular-signals when building modern, production-ready
applications that require:
- Fine-grained reactivity without RxJS
- Simpler state management
- Zone-less change detection
- Better performance for synchronous state
- Cleaner component code
- Angular 16+ applications
- Migrating from observables for sync state
- Component input/output as signals
## Signal Best Practices
1. **Use signals for synchronous state** - Perfect for component state
2. **Use computed for derived values** - Automatic dependency tracking
3. **Prefer signals over observables for state** - Simpler mental model
4. **Use effects sparingly** - Only for side effects
5. **Signal inputs for better types** - Type-safe component props
6. **Combine with observables when needed** - Use toSignal/toObservable
7. **Use custom equality for objects** - Optimize updates
8. **Leverage zone-less change detection** - Better performance
9. **Keep signals focused** - Small, single-purpose signals
10. **Use mutate carefully** - Prefer update for immutability
## Signal Pitfalls
1. **Overusing effects** - Can create complex dependencies
2. **Mutating signal values directly** - Use update/mutate methods
3. **Not understanding equality** - Objects update by reference
4. **Mixing patterns** - Choose signals OR observables per feature
5. **Effects in loops** - Can cause performance issues
6. **Not cleaning up effects** - Memory leaks
7. **Computed with side effects** - Should be pure functions
8. **Reading signals outside tracking context** - Won't track dependencies
9. **Complex effect dependencies** - Hard to debug
10. **Forgetting to call signal** - `count` vs `count()`
## Advanced Signal Patterns
### State Management Pattern
```typescript
import { signal, computed } from '@angular/core';
interface TodoState {
items: Todo[];
filter: 'all' | 'active' | 'completed';
}
@Injectable({
providedIn: 'root'
})
export class TodoStore {
// Private state
private state = signal<TodoState>({
items: [],
filter: 'all'
});
// Public selectors
items = computed(() => this.state().items);
filter = computed(() => this.state().filter);
filteredItems = computed(() => {
const items = this.items();
const filter = this.filter();
switch (filter) {
case 'active':
return items.filter(item => !item.completed);
case 'completed':
return items.filter(item => item.completed);
default:
return items;
}
});
// Actions
addTodo(text: string) {
this.state.update(state => ({
...state,
items: [...state.items, { id: Date.now(), text, completed: false }]
}));
}
toggleTodo(id: number) {
this.state.update(state => ({
...state,
items: state.items.map(item =>
item.id === id ? { ...item, completed: !item.completed } : item
)
}));
}
setFilter(filter: TodoState['filter']) {
this.state.update(state => ({ ...state, filter }));
}
}
```
### Signal-based Forms
```typescript
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-login-form'
})
export class LoginFormComponent {
email = signal('');
password = signal('');
emailError = computed(() => {
const email = this.email();
if (!email) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return 'Invalid email format';
}
return null;
});
passwordError = computed(() => {
const password = this.password();
if (!password) return 'Password is required';
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
});
isValid = computed(() =>
!this.emailError() && !this.passwordError() &&
this.email() && this.password()
);
submit() {
if (!this.isValid()) return;
// Submit form
}
}
```
## Resources
- [Angular Signals Documentation](https://angular.io/guide/signals)
- [Signal Inputs](https://angular.io/guide/signal-inputs)
- [Signal Queries](https://angular.io/guide/signal-queries)
- [Angular RxJS Interop](https://angular.io/guide/rxjs-interop)
- [Signals RFC](https://github.com/angular/angular/discussions/49090)
- [Angular Blog - Signals](https://blog.angular.io/angular-v16-is-here-4d7a28ec680d)
More from TheBushidoCollective/han
- absinthe-resolversUse when implementing GraphQL resolvers with Absinthe. Covers resolver patterns, dataloader integration, batching, and error handling.
- absinthe-schemaUse when designing GraphQL schemas with Absinthe. Covers type definitions, interfaces, unions, enums, and schema organization patterns.
- absinthe-subscriptionsUse when implementing real-time GraphQL subscriptions with Absinthe. Covers Phoenix channels, PubSub, and subscription patterns.
- act-docker-setupUse when configuring Docker environments for act, selecting runner images, managing container resources, or troubleshooting Docker-related issues with local GitHub Actions testing.
- act-local-testingUse when testing GitHub Actions workflows locally with act. Covers act CLI usage, Docker configuration, debugging workflows, and troubleshooting common issues when running workflows on your local machine.
- act-workflow-syntaxUse when creating or modifying GitHub Actions workflow files. Provides guidance on workflow syntax, triggers, jobs, steps, and expressions for creating valid GitHub Actions workflows that can be tested locally with act.
- ameba-configurationUse when configuring Ameba rules and settings for Crystal projects including .ameba.yml setup, rule management, severity levels, and code quality enforcement.
- ameba-custom-rulesUse when creating custom Ameba rules for Crystal code analysis including rule development, AST traversal, issue reporting, and rule testing.
- ameba-integrationUse when integrating Ameba into development workflows including CI/CD pipelines, pre-commit hooks, GitHub Actions, and automated code review processes.
- analyze-performanceAnalyze performance metrics and identify slow transactions in Sentry