Angular Standalone Components
Modern Angular development with standalone components, signals, and latest patterns
# Angular Standalone Components Best Practices
Comprehensive guide for building modern Angular applications with standalone components, signals, and the latest Angular patterns.
---
## Core Standalone Component Principles
1. **Standalone Component Architecture**
- Use standalone components to eliminate NgModule boilerplate
- Import dependencies directly in component metadata
- Create tree-shakable and modular applications
- Example standalone component:
```typescript
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-user-profile',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<div class="user-profile">
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
@if (loading()) {
<div class="loading">Loading...</div>
}
@for (post of posts(); track post.id) {
<div class="post">{{ post.title }}</div>
}
</div>
`,
styles: [`
.user-profile {
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.loading {
text-align: center;
color: #666;
}
`]
})
export class UserProfileComponent {
private http = inject(HttpClient);
private router = inject(Router);
// Signals for reactive state
user = signal<User | null>(null);
posts = signal<Post[]>([]);
loading = signal(false);
async ngOnInit() {
await this.loadUserData();
}
private async loadUserData() {
this.loading.set(true);
try {
const userData = await this.http.get<User>('/api/user').toPromise();
const userPosts = await this.http.get<Post[]>('/api/posts').toPromise();
this.user.set(userData);
this.posts.set(userPosts);
} finally {
this.loading.set(false);
}
}
}
```
2. **Dependency Injection with inject()**
- Use the new inject() function instead of constructor injection
- Leverage functional dependency injection patterns
- Create injectable services with providedIn: 'root'
- Example service injection:
```typescript
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class UserService {
private http = inject(HttpClient);
users = signal<User[]>([]);
loading = signal(false);
async loadUsers() {
this.loading.set(true);
try {
const users = await this.http.get<User[]>('/api/users').toPromise();
this.users.set(users);
} finally {
this.loading.set(false);
}
}
}
// Component usage
@Component({
selector: 'app-users',
standalone: true,
template: `
@for (user of userService.users(); track user.id) {
<div>{{ user.name }}</div>
}
`
})
export class UsersComponent {
userService = inject(UserService);
ngOnInit() {
this.userService.loadUsers();
}
}
```
3. **Signals for State Management**
- Replace BehaviorSubject and observables with signals where appropriate
- Use computed signals for derived state
- Implement effect() for side effects
- Example signal patterns:
```typescript
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div>
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<p>Is Even: {{ isEven() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
count = signal(0);
// Computed signals automatically update when dependencies change
doubleCount = computed(() => this.count() * 2);
isEven = computed(() => this.count() % 2 === 0);
constructor() {
// Effects run when signal dependencies change
effect(() => {
console.log(`Count changed to: ${this.count()}`);
localStorage.setItem('count', this.count().toString());
});
}
increment() {
this.count.update(current => current + 1);
}
decrement() {
this.count.update(current => current - 1);
}
reset() {
this.count.set(0);
}
}
```
---
## Modern Template Syntax
4. **Control Flow with @if, @for, @switch**
- Use new control flow syntax instead of structural directives
- Implement proper track functions for @for loops
- Handle empty states and loading conditions
- Example control flow patterns:
```typescript
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule],
template: `
<div class="product-list">
@if (loading()) {
<div class="loading-spinner">Loading products...</div>
} @else if (error()) {
<div class="error-message">{{ error() }}</div>
} @else {
@if (products().length > 0) {
@for (product of products(); track product.id) {
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>{{ product.description }}</p>
<span class="price">\${{ product.price }}</span>
@switch (product.category) {
@case ('electronics') {
<span class="category tech">Tech</span>
}
@case ('clothing') {
<span class="category fashion">Fashion</span>
}
@default {
<span class="category other">Other</span>
}
}
</div>
}
} @else {
<div class="empty-state">
<p>No products found</p>
<button (click)="loadProducts()">Retry</button>
</div>
}
}
</div>
`
})
export class ProductListComponent {
products = signal<Product[]>([]);
loading = signal(false);
error = signal<string | null>(null);
async loadProducts() {
this.loading.set(true);
this.error.set(null);
try {
const products = await this.productService.getProducts();
this.products.set(products);
} catch (err) {
this.error.set('Failed to load products');
} finally {
this.loading.set(false);
}
}
}
```
5. **Reactive Forms with Signals**
- Integrate reactive forms with signal-based state
- Implement form validation with signals
- Handle form submission and errors
- Example form integration:
```typescript
import { Component, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-user-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
type="text"
formControlName="name"
[class.error]="userForm.get('name')?.invalid && userForm.get('name')?.touched"
>
@if (userForm.get('name')?.invalid && userForm.get('name')?.touched) {
<div class="error-message">Name is required</div>
}
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
[class.error]="userForm.get('email')?.invalid && userForm.get('email')?.touched"
>
@if (userForm.get('email')?.invalid && userForm.get('email')?.touched) {
<div class="error-message">Valid email is required</div>
}
</div>
@if (submitError()) {
<div class="error-message">{{ submitError() }}</div>
}
<button
type="submit"
[disabled]="userForm.invalid || submitting()"
>
@if (submitting()) {
Saving...
} @else {
Save User
}
</button>
</form>
`
})
export class UserFormComponent {
private fb = inject(FormBuilder);
private userService = inject(UserService);
submitting = signal(false);
submitError = signal<string | null>(null);
userForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]]
});
async onSubmit() {
if (this.userForm.valid) {
this.submitting.set(true);
this.submitError.set(null);
try {
await this.userService.createUser(this.userForm.value);
this.userForm.reset();
} catch (error) {
this.submitError.set('Failed to save user');
} finally {
this.submitting.set(false);
}
}
}
}
```
---
## Component Communication
6. **Input and Output with Signals**
- Use signal inputs for reactive component properties
- Implement type-safe outputs with functions
- Handle optional inputs with default values
- Example component communication:
```typescript
// Child component
@Component({
selector: 'app-user-card',
standalone: true,
imports: [CommonModule],
template: `
<div class="user-card" [class.selected]="selected()">
<img [src]="user().avatar" [alt]="user().name">
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
<button (click)="onSelect()">
{{ selected() ? 'Deselect' : 'Select' }}
</button>
<button (click)="onEdit()" class="edit-btn">Edit</button>
</div>
`
})
export class UserCardComponent {
// Signal inputs
user = input.required<User>();
selected = input(false);
// Function outputs
userSelected = output<User>();
userEdit = output<User>();
onSelect() {
this.userSelected.emit(this.user());
}
onEdit() {
this.userEdit.emit(this.user());
}
}
// Parent component
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, UserCardComponent],
template: `
<div class="user-list">
@for (user of users(); track user.id) {
<app-user-card
[user]="user"
[selected]="selectedUsers().has(user.id)"
(userSelected)="toggleUserSelection($event)"
(userEdit)="editUser($event)"
/>
}
</div>
`
})
export class UserListComponent {
users = signal<User[]>([]);
selectedUsers = signal<Set<string>>(new Set());
toggleUserSelection(user: User) {
this.selectedUsers.update(selected => {
const newSet = new Set(selected);
if (newSet.has(user.id)) {
newSet.delete(user.id);
} else {
newSet.add(user.id);
}
return newSet;
});
}
editUser(user: User) {
this.router.navigate(['/users', user.id, 'edit']);
}
}
```
7. **Service Communication with Signals**
- Create reactive services using signals
- Implement global state management
- Handle cross-component communication
- Example service-based communication:
```typescript
@Injectable({
providedIn: 'root'
})
export class NotificationService {
private notificationsSignal = signal<Notification[]>([]);
notifications = this.notificationsSignal.asReadonly();
add(notification: Omit<Notification, 'id' | 'timestamp'>) {
const newNotification: Notification = {
...notification,
id: Date.now().toString(),
timestamp: new Date()
};
this.notificationsSignal.update(notifications =>
[...notifications, newNotification]
);
// Auto-remove after delay
setTimeout(() => {
this.remove(newNotification.id);
}, notification.duration || 5000);
}
remove(id: string) {
this.notificationsSignal.update(notifications =>
notifications.filter(n => n.id !== id)
);
}
clear() {
this.notificationsSignal.set([]);
}
}
// Usage in components
@Component({
selector: 'app-notification-list',
standalone: true,
template: `
<div class="notifications">
@for (notification of notificationService.notifications(); track notification.id) {
<div
class="notification"
[class]="notification.type"
>
<span>{{ notification.message }}</span>
<button (click)="notificationService.remove(notification.id)">×</button>
</div>
}
</div>
`
})
export class NotificationListComponent {
notificationService = inject(NotificationService);
}
```
---
## Routing and Navigation
8. **Functional Route Guards**
- Use functional guards instead of class-based guards
- Implement route protection with signals
- Handle navigation state reactively
- Example functional guards:
```typescript
import { inject } from '@angular/core';
import { Router, type CanActivateFn } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
// Redirect to login with return URL
router.navigate(['/login'], {
queryParams: { returnUrl: state.url }
});
return false;
};
export const adminGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated() && authService.isAdmin()) {
return true;
}
router.navigate(['/unauthorized']);
return false;
};
// Route configuration
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
canActivate: [authGuard]
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
canActivate: [authGuard, adminGuard]
},
{
path: 'profile/:id',
loadComponent: () => import('./profile/profile.component').then(m => m.ProfileComponent),
resolve: {
user: userResolver
}
}
];
// Functional resolver
export const userResolver: ResolveFn<User> = (route, state) => {
const userService = inject(UserService);
const userId = route.paramMap.get('id')!;
return userService.getUser(userId);
};
```
9. **Lazy Loading with Standalone Components**
- Implement route-level code splitting
- Load components and features on demand
- Optimize bundle size with lazy loading
- Example lazy loading setup:
```typescript
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: '/dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
loadComponent: () => import('./pages/dashboard/dashboard.component').then(m => m.DashboardComponent)
},
{
path: 'products',
loadChildren: () => import('./features/products/products.routes').then(m => m.productRoutes)
},
{
path: 'users',
loadChildren: () => import('./features/users/users.routes').then(m => m.userRoutes)
}
];
// features/products/products.routes.ts
export const productRoutes: Routes = [
{
path: '',
loadComponent: () => import('./product-list/product-list.component').then(m => m.ProductListComponent)
},
{
path: 'create',
loadComponent: () => import('./product-form/product-form.component').then(m => m.ProductFormComponent)
},
{
path: ':id',
loadComponent: () => import('./product-detail/product-detail.component').then(m => m.ProductDetailComponent)
},
{
path: ':id/edit',
loadComponent: () => import('./product-form/product-form.component').then(m => m.ProductFormComponent)
}
];
```
---
## Testing Standalone Components
10. **Component Testing Strategies**
- Test standalone components in isolation
- Mock dependencies and services
- Test signal interactions and computed values
- Example testing patterns:
```typescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserProfileComponent } from './user-profile.component';
import { UserService } from './user.service';
describe('UserProfileComponent', () => {
let component: UserProfileComponent;
let fixture: ComponentFixture<UserProfileComponent>;
let mockUserService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
const userServiceSpy = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
await TestBed.configureTestingModule({
imports: [UserProfileComponent],
providers: [
{ provide: UserService, useValue: userServiceSpy }
]
}).compileComponents();
mockUserService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
fixture = TestBed.createComponent(UserProfileComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load user data on init', async () => {
const mockUser = { id: '1', name: 'John Doe', email: 'john@example.com' };
mockUserService.getUser.and.returnValue(Promise.resolve(mockUser));
await component.ngOnInit();
expect(component.user()).toEqual(mockUser);
expect(component.loading()).toBe(false);
});
it('should handle loading state', () => {
component.loading.set(true);
fixture.detectChanges();
const loadingElement = fixture.debugElement.nativeElement.querySelector('.loading');
expect(loadingElement).toBeTruthy();
expect(loadingElement.textContent).toContain('Loading');
});
it('should update computed values when user changes', () => {
const user = { id: '1', name: 'John', email: 'john@example.com' };
component.user.set(user);
expect(component.displayName()).toBe('John');
component.user.update(u => ({ ...u!, name: 'Jane' }));
expect(component.displayName()).toBe('Jane');
});
});
// Service testing
describe('UserService', () => {
let service: UserService;
let httpMock: jasmine.SpyObj<HttpClient>;
beforeEach(() => {
const httpSpy = jasmine.createSpyObj('HttpClient', ['get', 'post', 'put', 'delete']);
TestBed.configureTestingModule({
providers: [
UserService,
{ provide: HttpClient, useValue: httpSpy }
]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
});
it('should load users and update signal', async () => {
const mockUsers = [
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' }
];
httpMock.get.and.returnValue(Promise.resolve(mockUsers));
await service.loadUsers();
expect(service.users()).toEqual(mockUsers);
expect(service.loading()).toBe(false);
});
});
```
---
## Summary Checklist
- [ ] Use standalone components to eliminate NgModule boilerplate
- [ ] Leverage inject() function for dependency injection
- [ ] Implement signals for reactive state management
- [ ] Use new control flow syntax (@if, @for, @switch)
- [ ] Create computed signals for derived state
- [ ] Implement functional route guards and resolvers
- [ ] Use lazy loading for optimal bundle size
- [ ] Write comprehensive tests for components and services
- [ ] Handle form state with reactive forms and signals
- [ ] Implement proper error handling and loading states
- [ ] Use signal inputs and function outputs for component communication
- [ ] Create reactive services with signal-based state
---
Follow these patterns to build modern, performant Angular applications using standalone components and the latest Angular features.