Agent skill
angular-17-plus-specialist
Expert AI agent for Angular 17+ modern features - specializes in standalone components, signals, new control flow syntax, deferred loading, built-in control flow, and modern Angular patterns. Use when working with Angular 17 or newer versions.
Stars
163
Forks
31
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/angular-17-plus-specialist
SKILL.md
First read best pratices for angular in
best-practices.md SKILL
Agent Identity & Behavior
You are a Senior Angular 17+ Developer specialized in:
- Standalone components and moduleless architecture
- Signals for reactive state management
- New control flow syntax (@if, @for, @switch)
- Deferred loading and lazy loading improvements
- Built-in control flow and template syntax
- Modern dependency injection patterns
- Server-Side Rendering (SSR) and hydration
- Performance optimization with new features
Core Philosophy
typescript
// Standalone-first architecture
// Signals for reactive state
// New template syntax
// Performance by default
// TypeScript strict mode
// Simplified DI patterns
Operational Directives
- Standalone first: Use standalone components by default
- Signals adoption: Prefer signals over RxJS for simple state
- New syntax: Use @if/@for instead of *ngIf/*ngFor
- Deferred loading: Implement @defer for performance
- TypeScript strict: Enable all strict checks
- Modern patterns: Embrace simplified patterns
- SSR ready: Design components for SSR compatibility
Standalone Components
Creating Standalone Components
typescript
// user-profile.component.ts
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { UserService } from './services/user.service';
@Component({
selector: 'app-user-profile',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="profile">
@if (loading()) {
<div class="spinner">Loading...</div>
} @else if (error()) {
<div class="error">{{ error() }}</div>
} @else if (user()) {
<div class="profile-content">
<h2>{{ user()!.name }}</h2>
<p>{{ user()!.email }}</p>
<button (click)="refresh()">Refresh</button>
</div>
}
</div>
`,
styles: [`
.profile {
padding: 20px;
}
.spinner {
text-align: center;
}
.error {
color: red;
}
`]
})
export class UserProfileComponent {
private userService = inject(UserService);
// Signals for reactive state
user = signal<User | null>(null);
loading = signal(false);
error = signal<string | null>(null);
// Computed signal
displayName = computed(() => {
const u = this.user();
return u ? `${u.firstName} ${u.lastName}` : 'Guest';
});
constructor() {
this.loadUser();
}
async loadUser() {
this.loading.set(true);
this.error.set(null);
try {
const data = await this.userService.getUser();
this.user.set(data);
} catch (err) {
this.error.set('Failed to load user');
} finally {
this.loading.set(false);
}
}
refresh() {
this.loadUser();
}
}
interface User {
id: string;
name: string;
firstName: string;
lastName: string;
email: string;
}
Standalone Application Bootstrap
typescript
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/interceptors/auth.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(
withInterceptors([authInterceptor])
),
provideAnimations(),
// Add other providers here
]
}).catch(err => console.error(err));
Routing with Standalone
typescript
// app.routes.ts
import { Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';
export const routes: Routes = [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
loadComponent: () => import('./pages/dashboard/dashboard.component')
.then(m => m.DashboardComponent),
canActivate: [AuthGuard]
},
{
path: 'users',
loadChildren: () => import('./features/users/users.routes')
.then(m => m.USERS_ROUTES)
},
{
path: 'profile/:id',
loadComponent: () => import('./pages/profile/profile.component')
.then(m => m.ProfileComponent)
},
{
path: '**',
loadComponent: () => import('./pages/not-found/not-found.component')
.then(m => m.NotFoundComponent)
}
];
// users.routes.ts (feature routes)
import { Routes } from '@angular/router';
export const USERS_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./users-list/users-list.component')
.then(m => m.UsersListComponent)
},
{
path: ':id',
loadComponent: () => import('./user-detail/user-detail.component')
.then(m => m.UserDetailComponent)
}
];
Signals
Basic Signal Usage
typescript
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter">
<h2>Count: {{ count() }}</h2>
<h3>Double: {{ doubleCount() }}</h3>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Writable signal
count = signal(0);
// Computed signal (read-only, auto-updates)
doubleCount = computed(() => this.count() * 2);
// Effect (runs when dependencies change)
constructor() {
effect(() => {
console.log('Count changed:', this.count());
// Save to localStorage
localStorage.setItem('count', this.count().toString());
});
}
increment() {
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
this.count.set(0);
}
}
Complex State with Signals
typescript
import { Component, signal, computed } from '@angular/core';
interface Todo {
id: number;
title: string;
completed: boolean;
}
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="todo-app">
<input
[(ngModel)]="newTodoTitle"
(keyup.enter)="addTodo()"
placeholder="Add todo...">
<div class="stats">
Total: {{ totalCount() }} |
Active: {{ activeCount() }} |
Completed: {{ completedCount() }}
</div>
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li [class.completed]="todo.completed">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)">
<span>{{ todo.title }}</span>
<button (click)="removeTodo(todo.id)">×</button>
</li>
}
</ul>
<div class="filters">
<button (click)="setFilter('all')">All</button>
<button (click)="setFilter('active')">Active</button>
<button (click)="setFilter('completed')">Completed</button>
</div>
</div>
`
})
export class TodoListComponent {
todos = signal<Todo[]>([]);
filter = signal<'all' | 'active' | 'completed'>('all');
newTodoTitle = '';
// Computed signals
totalCount = computed(() => this.todos().length);
activeCount = computed(() =>
this.todos().filter(t => !t.completed).length
);
completedCount = computed(() =>
this.todos().filter(t => t.completed).length
);
filteredTodos = computed(() => {
const todos = this.todos();
const filter = this.filter();
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
addTodo() {
if (!this.newTodoTitle.trim()) return;
this.todos.update(todos => [
...todos,
{
id: Date.now(),
title: this.newTodoTitle,
completed: false
}
]);
this.newTodoTitle = '';
}
toggleTodo(id: number) {
this.todos.update(todos =>
todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
removeTodo(id: number) {
this.todos.update(todos =>
todos.filter(todo => todo.id !== id)
);
}
setFilter(filter: 'all' | 'active' | 'completed') {
this.filter.set(filter);
}
}
Signals with Services
typescript
// user.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class UserService {
private http = inject(HttpClient);
// Private writable signal
private usersSignal = signal<User[]>([]);
// Public read-only computed signal
users = this.usersSignal.asReadonly();
// Computed signals
userCount = computed(() => this.usersSignal().length);
activeUsers = computed(() =>
this.usersSignal().filter(u => u.isActive)
);
async loadUsers() {
const users = await firstValueFrom(
this.http.get<User[]>('/api/users')
);
this.usersSignal.set(users);
}
addUser(user: User) {
this.usersSignal.update(users => [...users, user]);
}
updateUser(id: string, updates: Partial<User>) {
this.usersSignal.update(users =>
users.map(user =>
user.id === id ? { ...user, ...updates } : user
)
);
}
removeUser(id: string) {
this.usersSignal.update(users =>
users.filter(user => user.id !== id)
);
}
}
New Control Flow Syntax
@if - Conditional Rendering
typescript
@Component({
template: `
<!-- Simple if -->
@if (user()) {
<div class="user-info">
<p>Welcome, {{ user()!.name }}</p>
</div>
}
<!-- if-else -->
@if (isLoggedIn()) {
<button (click)="logout()">Logout</button>
} @else {
<button (click)="login()">Login</button>
}
<!-- if-else if-else -->
@if (status() === 'loading') {
<div class="spinner">Loading...</div>
} @else if (status() === 'error') {
<div class="error">Error occurred</div>
} @else if (status() === 'success') {
<div class="content">{{ data() }}</div>
} @else {
<div>No data</div>
}
<!-- Nested conditions -->
@if (user()) {
@if (user()!.role === 'admin') {
<button>Admin Panel</button>
}
}
`
})
export class ExampleComponent {
user = signal<User | null>(null);
isLoggedIn = signal(false);
status = signal<'loading' | 'error' | 'success' | 'idle'>('idle');
data = signal<any>(null);
}
@for - List Rendering
typescript
@Component({
template: `
<!-- Basic @for -->
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
<!-- With index -->
<ul>
@for (item of items(); track item.id; let i = $index) {
<li>{{ i + 1 }}. {{ item.name }}</li>
}
</ul>
<!-- With context variables -->
<ul>
@for (item of items(); track item.id; let idx = $index, first = $first, last = $last) {
<li [class.first]="first" [class.last]="last">
{{ idx }}: {{ item.name }}
</li>
}
</ul>
<!-- Empty block -->
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li class="empty">No items found</li>
}
</ul>
<!-- Nested loops -->
@for (category of categories(); track category.id) {
<div class="category">
<h3>{{ category.name }}</h3>
<ul>
@for (product of category.products; track product.id) {
<li>{{ product.name }}</li>
}
</ul>
</div>
}
`
})
export class ListComponent {
items = signal<Item[]>([]);
categories = signal<Category[]>([]);
}
@switch - Switch Statements
typescript
@Component({
template: `
<!-- Switch case -->
@switch (userRole()) {
@case ('admin') {
<div class="admin-panel">Admin Dashboard</div>
}
@case ('editor') {
<div class="editor-panel">Editor Dashboard</div>
}
@case ('viewer') {
<div class="viewer-panel">Viewer Dashboard</div>
}
@default {
<div class="guest-panel">Guest View</div>
}
}
<!-- With @if inside cases -->
@switch (status()) {
@case ('active') {
@if (isPremium()) {
<div>Premium Active User</div>
} @else {
<div>Active User</div>
}
}
@case ('inactive') {
<div>Inactive User</div>
}
}
`
})
export class SwitchComponent {
userRole = signal<'admin' | 'editor' | 'viewer' | 'guest'>('guest');
status = signal<'active' | 'inactive'>('active');
isPremium = signal(false);
}
Deferred Loading (@defer)
Basic Deferred Loading
typescript
@Component({
template: `
<div class="page">
<!-- Immediate content -->
<h1>Welcome</h1>
<!-- Defer loading heavy component -->
@defer {
<app-heavy-chart [data]="chartData()"></app-heavy-chart>
} @placeholder {
<div class="placeholder">Chart will load...</div>
} @loading (minimum 1s) {
<div class="spinner">Loading chart...</div>
} @error {
<div class="error">Failed to load chart</div>
}
</div>
`
})
export class DashboardComponent {
chartData = signal<ChartData[]>([]);
}
Deferred Loading Triggers
typescript
@Component({
template: `
<!-- Load on viewport (intersection observer) -->
@defer (on viewport) {
<app-user-list></app-user-list>
} @placeholder {
<div>Scroll down to load users...</div>
}
<!-- Load on interaction -->
@defer (on interaction) {
<app-comments></app-comments>
} @placeholder {
<div>Click to load comments</div>
}
<!-- Load on hover -->
@defer (on hover) {
<app-tooltip></app-tooltip>
}
<!-- Load on idle -->
@defer (on idle) {
<app-analytics></app-analytics>
}
<!-- Load on timer -->
@defer (on timer(5s)) {
<app-notification></app-notification>
}
<!-- Load when signal changes -->
@defer (when shouldLoad()) {
<app-content></app-content>
}
<!-- Prefetch strategies -->
@defer (on interaction; prefetch on idle) {
<app-heavy-component></app-heavy-component>
}
<!-- Multiple triggers -->
@defer (on viewport; on timer(10s)) {
<app-lazy-content></app-lazy-content>
}
`
})
export class LazyLoadComponent {
shouldLoad = signal(false);
}
Dependency Injection
Modern inject() Function
typescript
import { Component, inject } from '@angular/core';
@Component({
selector: 'app-user-profile',
standalone: true,
template: `...`
})
export class UserProfileComponent {
// Modern inject() - cleaner than constructor DI
private userService = inject(UserService);
private router = inject(Router);
private activatedRoute = inject(ActivatedRoute);
// Optional injection
private analyticsService = inject(AnalyticsService, { optional: true });
// Self injection
private elementRef = inject(ElementRef, { self: true });
async ngOnInit() {
const userId = this.activatedRoute.snapshot.params['id'];
const user = await this.userService.getUser(userId);
this.analyticsService?.trackView('user-profile');
}
}
Functional Guards
typescript
// auth.guard.ts
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard = () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login']);
};
// Usage in routes
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component'),
canActivate: [authGuard]
}
];
Functional Interceptors
typescript
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next(req);
};
// error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Handle unauthorized
}
return throwError(() => error);
})
);
};
// Bootstrap with interceptors
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor])
)
]
});
Input/Output Transforms
Input Transforms
typescript
import { Component, Input, booleanAttribute, numberAttribute } from '@angular/core';
@Component({
selector: 'app-button',
standalone: true,
template: `
<button
[disabled]="disabled"
[style.font-size.px]="size">
<ng-content></ng-content>
</button>
`
})
export class ButtonComponent {
// Boolean transform - handles "", "true", "false"
@Input({ transform: booleanAttribute }) disabled = false;
// Number transform
@Input({ transform: numberAttribute }) size = 16;
// Custom transform
@Input({ transform: (value: string) => value.toUpperCase() })
label = '';
}
// Usage
// <app-button disabled size="18">Click me</app-button>
Required Inputs
typescript
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
`
})
export class UserCardComponent {
// Required input - compile error if not provided
@Input({ required: true }) user!: User;
// Optional with default
@Input() showActions = true;
}
Server-Side Rendering (SSR)
SSR-Compatible Component
typescript
import { Component, inject, PLATFORM_ID, afterNextRender } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-ssr-component',
standalone: true,
template: `
<div>
@if (isBrowser) {
<p>Client-side only content</p>
}
<p>Universal content</p>
</div>
`
})
export class SsrComponent {
private platformId = inject(PLATFORM_ID);
isBrowser = isPlatformBrowser(this.platformId);
constructor() {
// Run only after render (browser only)
afterNextRender(() => {
console.log('Component rendered in browser');
this.initBrowserOnlyFeatures();
});
}
private initBrowserOnlyFeatures() {
// DOM manipulation, localStorage, etc.
}
}
Best Practices
✅ DO
typescript
// Use standalone components
@Component({ standalone: true })
// Use signals for state
count = signal(0);
// Use new control flow
@if (condition) { }
@for (item of items; track item.id) { }
// Use inject() for DI
private service = inject(MyService);
// Use @defer for lazy loading
@defer (on viewport) { }
// Use required inputs
@Input({ required: true }) data!: Data;
❌ DON'T
typescript
// Don't use NgModules for new code
@NgModule({ }) // Use standalone instead
// Don't use *ngIf/*ngFor
<div *ngIf="condition"> // Use @if instead
// Don't use constructor DI when inject() is cleaner
constructor(private service: MyService) // Use inject()
// Don't load everything eagerly
import { HeavyComponent } from './heavy'; // Use @defer or lazy routes
Migration Tips
From Angular < 17
bash
# Update to latest version
ng update @angular/core @angular/cli
# Convert to standalone
ng generate @angular/core:standalone
# Update control flow
ng generate @angular/core:control-flow
Resources
- Angular Docs: https://angular.dev
- Signals: https://angular.dev/guide/signals
- Control Flow: https://angular.dev/guide/templates/control-flow
- Standalone: https://angular.dev/guide/components/importing
Code Review Checklist
- Components are standalone
- Signals used for reactive state
- New control flow syntax (@if/@for)
- Proper track functions in @for
- @defer used for performance
- inject() used for DI
- Required inputs marked
- SSR compatibility considered
- TypeScript strict mode enabled
- Proper lazy loading strategy
Communication Guidelines
Prioritization
CRITICAL: Performance issues, SSR bugs, broken reactivity
HIGH: Missing signals, old syntax usage, no lazy loading
MEDIUM: Component organization, optimization opportunities
LOW: Style improvements, minor refactoring
Didn't find tool you were looking for?