name: angular-best-practices description: Modern Angular v20+ best practices covering standalone components, signals, dependency injection, routing, forms, SSR, and testing. Use when building Angular applications or reviewing Angular code. Adapted from AnalogJS Angular Skills.
Angular Best Practices Skill
Adapted from AnalogJS Angular Skills for Angular v20+.
1. Components (Standalone Architecture)
Always Use Standalone Components
Every component, directive, and pipe should be standalone: true (the default in Angular v20+). Never create NgModules for new code.
@Component({
selector: 'app-user-card',
standalone: true,
imports: [DatePipe, NgOptimizedImage],
template: `
<div class="card">
<img [ngSrc]="user().avatar" width="80" height="80" />
<h3>{{ user().name }}</h3>
<p>Joined {{ user().joinDate | date:'mediumDate' }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
user = input.required<User>();
}
Use Signal Inputs and Outputs
Replace @Input() and @Output() decorators with signal-based equivalents:
// ❌ Old decorator pattern
@Input() name: string = '';
@Output() clicked = new EventEmitter<void>();
// ✅ Signal-based (Angular v17.1+)
name = input<string>('');
name = input.required<string>(); // required input
clicked = output<void>(); // output signal
Model Inputs for Two-Way Binding
// Component with two-way binding
value = model<string>(''); // creates input + output pair
value = model.required<string>();
// Usage
<app-input [(value)]="searchTerm" />
Host Bindings via host Property
@Component({
host: {
'role': 'button',
'[attr.aria-pressed]': 'pressed()',
'[class.active]': 'active()',
'(click)': 'handleClick()',
'(keydown.enter)': 'handleClick()',
},
})
Always Use OnPush Change Detection
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
OnPush works perfectly with signals — the component only re-renders when signal values change.
Content Projection
@Component({
template: `
<header>
<ng-content select="[card-header]" />
</header>
<div class="body">
<ng-content />
</div>
<footer>
<ng-content select="[card-footer]" />
</footer>
`,
})
export class CardComponent {}
// Usage
<app-card>
<h2 card-header>Title</h2>
<p>Body content</p>
<button card-footer>Action</button>
</app-card>
2. Signals
Signals are Angular's reactive primitive. Use them everywhere instead of RxJS for component state.
Core Signal Patterns
// Writable signal
count = signal(0);
count.set(5);
count.update(v => v + 1);
// Computed signal (derived, read-only)
doubleCount = computed(() => this.count() * 2);
// Linked signal (resets when source changes)
selectedItem = linkedSignal(() => this.items()[0]);
// Effect (side effects when signals change)
constructor() {
effect(() => {
console.log('Count changed:', this.count());
});
}
Signal vs RxJS Decision
| Use Case | Use |
|---|---|
| Component state | signal() |
| Derived values | computed() |
| Reset on source change | linkedSignal() |
| Side effects | effect() |
| HTTP requests | httpResource() or HttpClient |
| WebSocket streams | RxJS |
| Complex async (debounce/merge) | RxJS |
| Route params | Signal-based route inputs |
RxJS Interop
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// Observable → Signal
count$ = interval(1000);
count = toSignal(this.count$, { initialValue: 0 });
// Signal → Observable
searchTerm = signal('');
searchTerm$ = toObservable(this.searchTerm);
3. Dependency Injection
Use inject() Function
// ❌ Constructor injection
constructor(
private userService: UserService,
private router: Router,
) {}
// ✅ inject() function
private userService = inject(UserService);
private router = inject(Router);
Injection Tokens for Non-Class Values
const API_URL = new InjectionToken<string>('API_URL');
// Provide
bootstrapApplication(AppComponent, {
providers: [
{ provide: API_URL, useValue: 'https://api.example.com' },
],
});
// Inject
private apiUrl = inject(API_URL);
4. Routing
Functional Routing Configuration
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent),
canActivate: [authGuard],
},
{
path: 'admin',
loadChildren: () => import('./admin/routes').then(m => m.ADMIN_ROUTES),
canMatch: [adminGuard],
},
];
Functional Guards
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) return true;
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
Signal-Based Route Parameters
@Component({...})
export class UserComponent {
// Route param as signal input (Angular v17.1+)
id = input.required<string>();
user = computed(() => this.userService.getUser(this.id()));
}
// Enable in app config
provideRouter(routes, withComponentInputBinding())
5. HTTP & Data Fetching
httpResource() for Declarative Data Fetching
@Component({...})
export class UsersComponent {
searchTerm = signal('');
usersResource = httpResource<User[]>(() => ({
url: '/api/users',
params: { search: this.searchTerm() },
}));
users = this.usersResource.value; // Signal<User[] | undefined>
isLoading = this.usersResource.isLoading; // Signal<boolean>
error = this.usersResource.error; // Signal<Error | undefined>
}
resource() for Generic Async
userResource = resource({
request: () => this.userId(),
loader: async ({ request: id }) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
},
});
HttpClient for Complex Requests
private http = inject(HttpClient);
updateUser(id: string, data: Partial<User>) {
return this.http.put<User>(`/api/users/${id}`, data).pipe(
catchError(this.handleError),
);
}
6. Forms (Signal Forms — Experimental)
Signal Forms is the new Angular forms API. For production, Reactive Forms is still valid.
Signal Forms Setup
import { SignalForm, FormField } from '@angular/forms/signal';
@Component({
template: `
<form (ngSubmit)="onSubmit()">
<input [formField]="name" />
@if (name.hasError('required')) {
<span class="error">Name is required</span>
}
<button type="submit" [disabled]="!form.valid()">Save</button>
</form>
`,
})
export class UserFormComponent {
form = new SignalForm({
name: new FormField('', { validators: [Validators.required] }),
email: new FormField('', { validators: [Validators.required, Validators.email] }),
});
name = this.form.controls.name;
onSubmit() {
if (this.form.valid()) {
console.log(this.form.value());
}
}
}
Reactive Forms (Stable)
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
address: this.fb.group({
street: [''],
city: ['', Validators.required],
}),
});
7. SSR & Hydration
Setup
// app.config.server.ts
export const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
provideServerRoutesConfig([
{ path: '**', renderMode: RenderMode.Server },
{ path: 'static/**', renderMode: RenderMode.Prerender },
{ path: 'api/**', renderMode: RenderMode.Server },
]),
],
};
Browser-Only Code
Never access window, document, or localStorage during SSR:
// ❌ Breaks SSR
ngOnInit() {
const width = window.innerWidth;
}
// ✅ Use afterNextRender
constructor() {
afterNextRender(() => {
const width = window.innerWidth;
this.setupResize();
});
}
Incremental Hydration with @defer
<!-- Lazy hydrate on viewport visibility -->
@defer (hydrate on viewport) {
<app-comments [postId]="post.id" />
} @placeholder {
<div class="comments-skeleton">Loading comments...</div>
}
<!-- Hydrate on user interaction -->
@defer (hydrate on interaction) {
<app-rich-editor />
} @placeholder {
<textarea placeholder="Click to edit..."></textarea>
}
HTTP Caching for SSR
// Cache API responses during SSR
export const serverConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch()),
{
provide: HTTP_TRANSFER_CACHE_INTERCEPTOR_FN,
useValue: (req: HttpRequest<unknown>) => {
return !req.url.includes('/api/user'); // don't cache user-specific data
},
},
],
};
8. Directives
Attribute Directives
@Directive({
selector: '[appHighlight]',
standalone: true,
host: {
'(mouseenter)': 'onMouseEnter()',
'(mouseleave)': 'onMouseLeave()',
'[style.backgroundColor]': 'bgColor()',
},
})
export class HighlightDirective {
color = input('yellow', { alias: 'appHighlight' });
bgColor = signal('');
onMouseEnter() { this.bgColor.set(this.color()); }
onMouseLeave() { this.bgColor.set(''); }
}
Host Directive Composition
@Component({
hostDirectives: [
{ directive: TooltipDirective, inputs: ['tooltip'] },
{ directive: RippleDirective },
],
})
export class ButtonComponent {}
9. Testing (Vitest)
Setup
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vitest-angular';
export default defineConfig({
plugins: [angular()],
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
},
});
Component Tests
import { render, screen } from '@testing-library/angular';
it('should display user name', async () => {
await render(UserCardComponent, {
inputs: { user: { name: 'Alice', avatar: '/alice.png' } },
});
expect(screen.getByText('Alice')).toBeTruthy();
});
it('should emit click event', async () => {
const onClick = vi.fn();
await render(UserCardComponent, {
inputs: { user: mockUser },
on: { clicked: onClick },
});
screen.getByRole('button').click();
expect(onClick).toHaveBeenCalled();
});
Signal Testing
it('should compute derived values', () => {
TestBed.runInInjectionContext(() => {
const count = signal(0);
const doubled = computed(() => count() * 2);
expect(doubled()).toBe(0);
count.set(5);
expect(doubled()).toBe(10);
});
});
Service Testing with Mocks
it('should fetch users', async () => {
const mockHttp = { get: vi.fn().mockReturnValue(of(mockUsers)) };
TestBed.configureTestingModule({
providers: [
UserService,
{ provide: HttpClient, useValue: mockHttp },
],
});
const service = TestBed.inject(UserService);
const users = await firstValueFrom(service.getUsers());
expect(users).toEqual(mockUsers);
});
10. Tooling & CLI
Code Generation
ng generate component features/user-card --standalone
ng generate service core/auth
ng generate guard core/auth --functional
ng generate pipe shared/truncate --standalone
Build Configuration
// angular.json — production optimization
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" }
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false
}
}
Quick Reference
| Area | Modern Pattern | Avoid |
|---|---|---|
| Components | standalone: true + signal inputs |
NgModules, @Input() decorator |
| State | signal(), computed() |
Plain variables, BehaviorSubject for local state |
| DI | inject() function |
Constructor injection |
| Routing | Functional guards, loadComponent() |
Class-based guards, eager loading |
| HTTP | httpResource(), resource() |
Manual subscribe/unsubscribe |
| Forms | Signal Forms or Reactive Forms | Template-driven forms |
| SSR | afterNextRender(), @defer hydration |
Direct window/document access |
| Testing | Vitest + Testing Library | Karma, Jasmine |
| Change Detection | OnPush |
Default change detection |
Attribution
Based on AnalogJS Angular Skills for Angular v20+.