Imported from drgaciw/agents-skills-repo (
Skills/angular-signals-expert/SKILL.md). Install upstream withnpx skills add drgaciw/agents-skills-repo --skill angular-signals-expert. Copyright stays with the author.
Angular Signals Expert
You are an expert in Angular's Signals system - the fine-grained reactivity model that simplifies state management and enables compile-time optimizations for faster applications.
Core Concepts
What Are Signals?
Signals are reactive primitives that hold values and notify consumers when those values change. They provide:
- Fine-grained reactivity: Only affected parts of the UI update
- Synchronous reads: No subscriptions needed to read values
- Compile-time optimizations: Angular can optimize change detection
- Simpler mental model: Easier than RxJS for most state management
Signal Types
1. Writable Signals
import { signal } from '@angular/core';
// Creating a writable signal
const count = signal(0);
// Reading the value (call like a function)
console.log(count()); // 0
// Setting a new value
count.set(5);
// Updating based on previous value
count.update(value => value + 1);
2. Computed Signals
Derived values that automatically update when dependencies change.
import { signal, computed } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
// Computed signal - recalculates when dependencies change
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // "John Doe"
firstName.set('Jane');
console.log(fullName()); // "Jane Doe"
Best Practices for Computed:
- Keep computation pure (no side effects)
- Computed signals are lazy - only recalculate when read
- Dependencies are tracked automatically
3. Effects
Side effects that run when signal values change.
import { signal, effect } from '@angular/core';
const count = signal(0);
// Effect runs when count changes
effect(() => {
console.log(`Count is now: ${count()}`);
// Runs immediately, then on each change
});
// Effects must be created in injection context
@Component({...})
export class MyComponent {
count = signal(0);
constructor() {
// Effect created in constructor (injection context)
effect(() => {
console.log(`Count: ${this.count()}`);
});
}
}
Effect Cleanup:
effect((onCleanup) => {
const subscription = someObservable.subscribe();
// Cleanup function runs before next effect execution
onCleanup(() => {
subscription.unsubscribe();
});
});
Signal-Based Component APIs
Signal Inputs
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div class="card">
<h2>{{ name() }}</h2>
<p>Age: {{ age() }}</p>
@if (showDetails()) {
<p>Details visible</p>
}
</div>
`
})
export class UserCardComponent {
// Required input
name = input.required<string>();
// Optional input with default
age = input(0);
// Optional input (undefined if not provided)
showDetails = input<boolean>();
// Input with alias
userId = input.required<string>({ alias: 'id' });
// Input with transform
disabled = input(false, {
transform: (value: boolean | string) =>
typeof value === 'string' ? value !== 'false' : value
});
}
Signal Outputs
import { Component, output } from '@angular/core';
@Component({
selector: 'app-button',
template: `<button (click)="handleClick()">Click me</button>`
})
export class ButtonComponent {
// Output signal
clicked = output<void>();
// Output with payload
valueChange = output<number>();
// Output with alias
onSelect = output<string>({ alias: 'select' });
handleClick() {
this.clicked.emit();
this.valueChange.emit(42);
}
}
Model Inputs (Two-Way Binding)
import { Component, model } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<button (click)="decrement()">-</button>
<span>{{ value() }}</span>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
// Two-way bindable signal
value = model(0);
// Required model
// selectedId = model.required<string>();
increment() {
this.value.update(v => v + 1);
}
decrement() {
this.value.update(v => v - 1);
}
}
// Parent usage:
// <app-counter [(value)]="parentCount" />
Linked Signals
Create signals that are linked to other signals with transformation.
import { signal, linkedSignal } from '@angular/core';
const items = signal(['apple', 'banana', 'cherry']);
// Linked signal that resets when source changes
const selectedIndex = linkedSignal({
source: items,
computation: () => 0 // Reset to 0 when items change
});
// With previous value access
const selectedItem = linkedSignal({
source: items,
computation: (source, previous) => {
const prevValue = previous?.value;
// Keep selection if item still exists
if (prevValue && source.includes(prevValue)) {
return prevValue;
}
return source[0];
}
});
Resource API (Async Data)
Basic Resource
import { resource } from '@angular/core';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({...})
export class UserListComponent {
private http = inject(HttpClient);
usersResource = resource({
loader: () => this.http.get<User[]>('/api/users')
});
// Access resource state
users = this.usersResource.value; // Signal<User[] | undefined>
isLoading = this.usersResource.isLoading; // Signal<boolean>
error = this.usersResource.error; // Signal<Error | undefined>
status = this.usersResource.status; // Signal<ResourceStatus>
refresh() {
this.usersResource.reload();
}
}
Resource with Request Parameters
@Component({...})
export class UserDetailComponent {
userId = input.required<string>();
userResource = resource({
request: () => ({ id: this.userId() }),
loader: ({ request }) =>
this.http.get<User>(`/api/users/${request.id}`)
});
}
RxResource (Observable-based)
import { rxResource } from '@angular/core/rxjs-interop';
@Component({...})
export class DataComponent {
private http = inject(HttpClient);
dataResource = rxResource({
loader: () => this.http.get<Data[]>('/api/data')
});
}
RxJS Interoperability
toSignal - Observable to Signal
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({...})
export class TimerComponent {
// Convert observable to signal
counter = toSignal(interval(1000), { initialValue: 0 });
// Without initial value (undefined initially)
data = toSignal(this.dataService.getData());
// With requireSync for synchronous observables
route = toSignal(this.route.params, { requireSync: true });
}
toObservable - Signal to Observable
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime } from 'rxjs/operators';
@Component({...})
export class SearchComponent {
searchTerm = signal('');
// Convert signal to observable for complex async operations
searchResults$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
switchMap(term => this.searchService.search(term))
);
// Convert back to signal if needed
searchResults = toSignal(this.searchResults$, { initialValue: [] });
}
State Management Patterns
Service with Signals
@Injectable({ providedIn: 'root' })
export class TodoService {
// Private writable state
private todosState = signal<Todo[]>([]);
private loadingState = signal(false);
private errorState = signal<string | null>(null);
// Public readonly signals
todos = this.todosState.asReadonly();
loading = this.loadingState.asReadonly();
error = this.errorState.asReadonly();
// Computed values
completedTodos = computed(() =>
this.todosState().filter(t => t.completed)
);
pendingCount = computed(() =>
this.todosState().filter(t => !t.completed).length
);
// Actions
addTodo(title: string) {
const newTodo: Todo = {
id: crypto.randomUUID(),
title,
completed: false
};
this.todosState.update(todos => [...todos, newTodo]);
}
toggleTodo(id: string) {
this.todosState.update(todos =>
todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
)
);
}
removeTodo(id: string) {
this.todosState.update(todos =>
todos.filter(t => t.id !== id)
);
}
}
Component Store Pattern
@Injectable()
export class CounterStore {
// State
private state = signal({
count: 0,
loading: false
});
// Selectors
count = computed(() => this.state().count);
loading = computed(() => this.state().loading);
// Reducers
increment() {
this.state.update(s => ({ ...s, count: s.count + 1 }));
}
decrement() {
this.state.update(s => ({ ...s, count: s.count - 1 }));
}
setLoading(loading: boolean) {
this.state.update(s => ({ ...s, loading }));
}
}
Migration from RxJS
Before (RxJS BehaviorSubject)
// Old approach
@Injectable({ providedIn: 'root' })
export class UserService {
private userSubject = new BehaviorSubject<User | null>(null);
user$ = this.userSubject.asObservable();
setUser(user: User) {
this.userSubject.next(user);
}
}
// Component
@Component({...})
export class ProfileComponent {
user$ = this.userService.user$;
// Template: {{ user$ | async }}
}
After (Signals)
// New approach
@Injectable({ providedIn: 'root' })
export class UserService {
private userState = signal<User | null>(null);
user = this.userState.asReadonly();
setUser(user: User) {
this.userState.set(user);
}
}
// Component
@Component({...})
export class ProfileComponent {
private userService = inject(UserService);
user = this.userService.user;
// Template: {{ user()?.name }}
}
Best Practices
Do's
- Use
signal()for component state - Use
computed()for derived values - Use
effect()sparingly - only for side effects - Use
asReadonly()to expose read-only signals from services - Prefer signal inputs over decorator-based
@Input() - Use
resource()for async data fetching
Don'ts
- Don't read signals in tight loops (cache the value)
- Don't mutate objects/arrays in signals directly (create new references)
- Don't create effects outside injection context
- Don't overuse effects for data transformation (use computed instead)
- Don't mix signals and RxJS unnecessarily
Performance Tips
// Good: Immutable update
this.items.update(items => [...items, newItem]);
// Bad: Mutating in place
this.items().push(newItem); // Won't trigger updates!
// Good: Granular signals
const firstName = signal('');
const lastName = signal('');
// Avoid: Large object signals when only parts change
const user = signal({ firstName: '', lastName: '', email: '', ... });
Debugging Signals
// Log signal changes
effect(() => {
console.log('State changed:', this.state());
});
// Track computed recalculations
const expensive = computed(() => {
console.log('Recalculating...');
return heavyComputation(this.data());
});
Common Patterns
Form State with Signals
@Component({...})
export class FormComponent {
name = signal('');
email = signal('');
isValid = computed(() =>
this.name().length > 0 &&
this.email().includes('@')
);
formData = computed(() => ({
name: this.name(),
email: this.email()
}));
}
Loading/Error State
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
const dataState = signal<AsyncState<User>>({ status: 'idle' });
// Usage
const isLoading = computed(() => dataState().status === 'loading');
const data = computed(() =>
dataState().status === 'success' ? dataState().data : null
);