Angular Expert Skill
Expert-level Angular 20+ patterns for components, signals, RxJS, state management, accessibility, and performance.
Auto-Detection
This skill activates when:
- Working with Angular projects
- Detected
angular.jsonor@angular/corein package.json - Working with
*.component.ts,*.service.ts,*.directive.tsfiles - Using RxJS, NgRx, or Angular Material
TypeScript Best Practices
- Use strict type checking
- Prefer type inference when the type is obvious
- Avoid
any; useunknownwhen type is uncertain
1. Component Best Practices
Core Rules (Angular 20+)
- Always use standalone components.
- Must NOT set
standalone: trueinside Angular decorators — it is the default in v20+. - Use
input()andoutput()functions instead of@Input()and@Output()decorators. - Use
signal()for local state,computed()for derived state. - Set
changeDetection: ChangeDetectionStrategy.OnPushon every component. - Do NOT use
@HostBindingor@HostListener— use thehostobject on the decorator. - Do NOT use
ngClass— use[class.foo]="..."bindings. - Do NOT use
ngStyle— use[style.foo]="..."bindings. - Use
NgOptimizedImagefor static images (does not work for inline base64). - Prefer inline templates for small components.
- When using external templates/styles, use paths relative to the component TS file.
- Keep components small and focused on a single responsibility.
Standalone Component
// ✅ GOOD - v20+ standalone (no `standalone: true` flag)
@Component({
selector: 'app-user-card',
imports: [RouterLink, NgOptimizedImage],
template: `
<div class="user-card" [class.selected]="selected()">
<img ngSrc="/assets/avatar.png" width="48" height="48" alt="">
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
<a [routerLink]="['/users', user().id]">View Profile</a>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
user = input.required<User>();
selected = input(false);
pick = output<User>();
}
// ❌ BAD - v20+ violations
@Component({
selector: 'app-user-card',
standalone: true, // ❌ remove — default in v20+
imports: [CommonModule],
template: `<div [ngClass]="{ active: isActive }">...</div>`, // ❌ use [class.active]
})
export class UserCardComponent {
@Input({ required: true }) user!: User; // ❌ use input.required<User>()
@Output() pick = new EventEmitter<User>(); // ❌ use output<User>()
@HostBinding('class.dark') isDark = false; // ❌ use host: { '[class.dark]': 'isDark()' }
@HostListener('click') onClick() {} // ❌ use host: { '(click)': 'onClick()' }
}
Signal-Based State
// ✅ GOOD - signals + computed + OnPush
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button type="button" (click)="increment()">+</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterComponent {
count = signal(0);
doubleCount = computed(() => this.count() * 2);
increment() {
this.count.update(c => c + 1);
}
}
Host Bindings via host Object
// ✅ GOOD - host object replaces @HostBinding / @HostListener
@Component({
selector: 'app-toggle',
template: `<ng-content />`,
host: {
'[class.active]': 'active()',
'[attr.aria-pressed]': 'active()',
'(click)': 'toggle()',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToggleComponent {
active = signal(false);
toggle() {
this.active.update(v => !v);
}
}
Smart vs Dumb Components
// ✅ GOOD - container (smart)
@Component({
selector: 'app-users-container',
imports: [UserListComponent],
template: `
<app-user-list
[users]="users()"
[loading]="loading()"
(userSelected)="onUserSelected($event)"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UsersContainerComponent {
private userService = inject(UserService);
users = signal<User[]>([]);
loading = signal(false);
constructor() {
this.loadUsers();
}
private async loadUsers() {
this.loading.set(true);
this.users.set(await this.userService.getUsers());
this.loading.set(false);
}
onUserSelected(user: User) {
this.userService.selectUser(user);
}
}
// ✅ GOOD - presentational (dumb)
@Component({
selector: 'app-user-list',
template: `
@if (loading()) {
<div class="loading">Loading…</div>
} @else {
<ul>
@for (user of users(); track user.id) {
<li (click)="userSelected.emit(user)">{{ user.name }}</li>
}
</ul>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserListComponent {
users = input<User[]>([]);
loading = input(false);
userSelected = output<User>();
}
2. Services & Dependency Injection
- Design services around a single responsibility.
- Use
providedIn: 'root'for singleton services. - Use the
inject()function instead of constructor injection.
// ✅ GOOD - Service with inject()
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private baseUrl = inject(API_BASE_URL);
getUsers(): Observable<User[]> {
return this.http.get<User[]>(`${this.baseUrl}/users`);
}
getUser(id: string): Observable<User> {
return this.http.get<User>(`${this.baseUrl}/users/${id}`);
}
createUser(user: CreateUserDto): Observable<User> {
return this.http.post<User>(`${this.baseUrl}/users`, user);
}
}
Injection Tokens
// ✅ GOOD - Injection tokens for config
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
// In app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{ provide: API_BASE_URL, useValue: environment.apiUrl },
],
};
3. Templates & Control Flow
- Keep templates simple — avoid complex logic.
- Use native control flow (
@if,@for,@switch) instead of*ngIf,*ngFor,*ngSwitch. - Use the
asyncpipe to handle observables. - Do not assume globals like
new Date()are available in templates.
<!-- ✅ GOOD - native control flow + async pipe -->
@if (user$ | async; as user) {
<app-user-card [user]="user" />
} @else {
<p>No user loaded.</p>
}
@for (item of items(); track item.id) {
<app-item [item]="item" />
} @empty {
<p>No items.</p>
}
4. RxJS Best Practices
Declarative Streams
// ✅ GOOD - bridging RxJS and signals
@Component({...})
export class UsersComponent {
private userService = inject(UserService);
private route = inject(ActivatedRoute);
private userId = toSignal(
this.route.paramMap.pipe(map(params => params.get('id')))
);
user = toSignal(
toObservable(this.userId).pipe(
filter((id): id is string => id != null),
switchMap(id => this.userService.getUser(id)),
)
);
}
Error Handling
// ✅ GOOD - catchError with recovery
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users').pipe(
retry({ count: 3, delay: 1000 }),
catchError(error => {
console.error('Failed to fetch users', error);
return of([]);
}),
);
}
Unsubscribe Patterns
// ✅ GOOD - takeUntilDestroyed
@Component({...})
export class MyComponent {
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.someObservable$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
// handle value
});
}
}
// ✅ GOOD - async pipe (auto-unsubscribes)
@Component({
template: `
@if (users$ | async; as users) {
<app-user-list [users]="users" />
}
`,
})
export class UsersComponent {
users$ = this.userService.getUsers();
}
5. State Management
Signals (Local State)
- Use signals for local component state.
- Use
computed()for derived state. - Keep state transformations pure and predictable.
- Do NOT use
mutateon signals — useupdateorset.
NgRx (Global State)
// ✅ GOOD - NgRx feature with createFeature
export const usersFeature = createFeature({
name: 'users',
reducer: createReducer(
initialState,
on(UsersActions.loadUsers, state => ({ ...state, loading: true })),
on(UsersActions.loadUsersSuccess, (state, { users }) => ({
...state,
users,
loading: false,
})),
on(UsersActions.loadUsersFailure, (state, { error }) => ({
...state,
error,
loading: false,
})),
),
});
export const {
selectUsers,
selectLoading,
selectError,
} = usersFeature;
// ✅ GOOD - createActionGroup
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Load Users': emptyProps(),
'Load Users Success': props<{ users: User[] }>(),
'Load Users Failure': props<{ error: string }>(),
'Select User': props<{ userId: string }>(),
},
});
// ✅ GOOD - Functional effects
export const loadUsers = createEffect(
(actions$ = inject(Actions), userService = inject(UserService)) => {
return actions$.pipe(
ofType(UsersActions.loadUsers),
exhaustMap(() =>
userService.getUsers().pipe(
map(users => UsersActions.loadUsersSuccess({ users })),
catchError(error =>
of(UsersActions.loadUsersFailure({ error: error.message }))
),
),
),
);
},
{ functional: true },
);
6. Forms
- Prefer Reactive forms over Template-driven forms.
- Use
NonNullableFormBuilderfor typed reactive forms.
// ✅ GOOD - typed reactive forms
@Component({...})
export class UserFormComponent {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
name: ['', [Validators.required, Validators.minLength(2)]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
onSubmit() {
if (this.form.valid) {
const value = this.form.getRawValue();
// value: { email: string; name: string; password: string }
this.save(value);
}
}
}
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label for="email">Email</label>
<input id="email" formControlName="email" type="email">
@if (form.controls.email.errors?.['required']) {
<span class="error">Email is required</span>
}
@if (form.controls.email.errors?.['email']) {
<span class="error">Invalid email format</span>
}
</div>
<button type="submit" [disabled]="form.invalid">Submit</button>
</form>
7. Routing
- Implement lazy loading for feature routes.
- Use functional guards, resolvers, and interceptors.
// ✅ GOOD - lazy loaded routes
export const routes: Routes = [
{
path: 'users',
loadComponent: () => import('./users/users.component').then(m => m.UsersComponent),
children: [
{
path: ':id',
loadComponent: () => import('./users/user-detail.component').then(m => m.UserDetailComponent),
},
],
},
];
// ✅ GOOD - functional guard
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// ✅ GOOD - functional resolver
export const userResolver: ResolveFn<User> = (route) => {
const userService = inject(UserService);
const userId = route.paramMap.get('id')!;
return userService.getUser(userId);
};
8. Performance Optimization
// ✅ GOOD - OnPush + track + @defer
@Component({
template: `
@for (user of users(); track user.id) {
<app-user-card [user]="user" />
}
@defer (on viewport) {
<app-heavy-component />
} @placeholder {
<div class="skeleton"></div>
} @loading {
<div class="spinner"></div>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyComponent {
users = input<User[]>([]);
}
9. HTTP Interceptors
// ✅ GOOD - functional interceptor
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);
};
// In app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
],
};
10. Accessibility (Required)
Code MUST pass all AXE checks and meet WCAG AA minimums. This is non-negotiable.
Hard requirements:
- Semantic HTML: use
<button>,<nav>,<main>,<header>,<footer>over<div>with click handlers. - Focus management: every interactive element must be keyboard reachable; manage focus on route change and dialog open/close.
- Color contrast: text contrast ≥ 4.5:1 (normal), ≥ 3:1 (large). Verify with AXE / Lighthouse.
- ARIA attributes: label icon-only buttons (
aria-label), associate labels (for/id), usearia-livefor dynamic announcements. - Visible focus indicator: never remove
:focus-visibleoutline without replacing it. - Form errors: associate with
aria-describedbyand userole="alert"for live error announcements.
<!-- ✅ GOOD - accessible icon button -->
<button type="button" aria-label="Close dialog" (click)="close()">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<!-- ✅ GOOD - associated label + error -->
<label for="email">Email</label>
<input id="email" formControlName="email" type="email"
[attr.aria-invalid]="form.controls.email.invalid"
aria-describedby="email-error">
@if (form.controls.email.invalid) {
<span id="email-error" role="alert">Enter a valid email.</span>
}
<!-- ❌ BAD -->
<div (click)="close()">×</div> <!-- not keyboard-reachable, no name -->
<button (click)="close()">×</button> <!-- no accessible name -->
<input formControlName="email"> <!-- no label -->
11. Testing
// ✅ GOOD - component testing (no `standalone: true` needed in v20+)
describe('UserCardComponent', () => {
let fixture: ComponentFixture<UserCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(UserCardComponent);
});
it('should display user name', () => {
fixture.componentRef.setInput('user', { id: '1', name: 'John', email: 'j@x.com' });
fixture.detectChanges();
const nameElement = fixture.nativeElement.querySelector('h3');
expect(nameElement.textContent).toContain('John');
});
it('should emit when picked', () => {
const component = fixture.componentInstance;
fixture.componentRef.setInput('user', { id: '1', name: 'John', email: 'j@x.com' });
jest.spyOn(component.pick, 'emit');
fixture.nativeElement.querySelector('.user-card').click();
expect(component.pick.emit).toHaveBeenCalled();
});
});
Quick Reference
checklist[16]{rule,enforcement}:
Standalone,Default in v20+ - never set standalone: true
Inputs/Outputs,Use input() / output() functions not @Input/@Output
Host bindings,Use host: {} object not @HostBinding/@HostListener
Class bindings,Use [class.x] not [ngClass]
Style bindings,Use [style.x] not [ngStyle]
Images,Use NgOptimizedImage for static assets
Change detection,OnPush on every component
State,signal() local + computed() derived + NgRx global
Signal mutation,update() or set() never mutate()
Templates,Native @if/@for/@switch + async pipe
DI,inject() function with providedIn: 'root'
Forms,Reactive + NonNullableFormBuilder
Routes,Lazy loadComponent + functional guards
HTTP,Functional interceptors via provideHttpClient
RxJS,takeUntilDestroyed or async pipe
Accessibility,AXE clean + WCAG AA + semantic HTML + ARIA
Version: 2.0.0