Angular 17+ introduced standalone components, the Signals API, and control flow syntax that eliminate much of the boilerplate that made Angular feel verbose. Claude Code generates modern Angular code — standalone components without NgModule, Signals for fine-grained reactivity, and RxJS only where async streams are genuinely the right model.
This guide covers Angular 17+ with Claude Code: standalone components, Signals, RxJS patterns, NgRx, and testing.
Angular 17+ Setup
Set up a new Angular 17 project. I want: standalone components as default,
Signals for state management, strict TypeScript, and Jest for tests.
Skip NgModules — I want the modern approach.
## Angular Project Conventions
- Angular 17+ with standalone components (no NgModule)
- Signals API for local state — avoid Subject/BehaviorSubject for simple state
- RxJS for HTTP, complex async, and event streams
- Angular Material for UI components
- NgRx Signals Store for complex global state (not full NgRx unless required)
- Jest (not Karma/Jasmine) — @angular-builders/jest
- TypeScript strict mode + Angular strict template checking
- New control flow syntax (@if, @for, @switch) — no *ngIf, *ngFor directives
- inject() function — not constructor injection
Standalone Components with Signals
Create a ProductList component that fetches products,
supports filtering and sorting, and uses the new Signals API.
// src/app/features/products/product-list.component.ts
import {
Component, computed, inject, signal, OnInit
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { ProductCardComponent } from './product-card.component';
import { toSignal } from '@angular/core/rxjs-interop';
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
@Component({
selector: 'app-product-list',
standalone: true,
imports: [FormsModule, ProductCardComponent],
template: `
<div class="controls">
<input
type="search"
[(ngModel)]="searchQuery"
placeholder="Search products..."
aria-label="Search products"
/>
<select [(ngModel)]="selectedCategory" aria-label="Filter by category">
<option value="">All categories</option>
@for (category of categories(); track category) {
<option [value]="category">{{ category }}</option>
}
</select>
<label>
<input type="checkbox" [(ngModel)]="inStockOnly" />
In stock only
</label>
</div>
@if (isLoading()) {
<p>Loading products...</p>
} @else if (error()) {
<p class="error" role="alert">{{ error() }}</p>
} @else {
<p>{{ filteredProducts().length }} products found</p>
<div class="product-grid">
@for (product of filteredProducts(); track product.id) {
<app-product-card [product]="product" />
} @empty {
<p>No products match your filters.</p>
}
</div>
}
`,
})
export class ProductListComponent implements OnInit {
private http = inject(HttpClient);
// State as Signals
private products = signal<Product[]>([]);
readonly isLoading = signal(false);
readonly error = signal<string | null>(null);
// Two-way bound inputs (writable signals via ngModel)
searchQuery = '';
selectedCategory = '';
inStockOnly = false;
// Derived state via computed()
readonly categories = computed(() =>
[...new Set(this.products().map(p => p.category))].sort()
);
readonly filteredProducts = computed(() => {
let result = this.products();
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
result = result.filter(p => p.name.toLowerCase().includes(q));
}
if (this.selectedCategory) {
result = result.filter(p => p.category === this.selectedCategory);
}
if (this.inStockOnly) {
result = result.filter(p => p.inStock);
}
return result;
});
ngOnInit() {
this.loadProducts();
}
private async loadProducts() {
this.isLoading.set(true);
this.error.set(null);
try {
const products = await this.http.get<Product[]>('/api/products').toPromise();
this.products.set(products ?? []);
} catch {
this.error.set('Failed to load products. Please try again.');
} finally {
this.isLoading.set(false);
}
}
}
Key Angular 17 patterns: inject() instead of constructor injection, Signals for state, @if/@for control flow blocks instead of structural directives, standalone: true without NgModule.
Reactive Forms with Validation
Build a checkout form with Angular reactive forms.
Complex validation: card number format, expiry date in the future,
fields that depend on each other.
import {
Component, inject, signal
} from '@angular/core';
import {
FormBuilder, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors
} from '@angular/forms';
// Custom validator factory
function expiryDateValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value as string;
if (!value) return null;
const [month, year] = value.split('/').map(Number);
if (!month || !year) return { invalidFormat: true };
const expiry = new Date(2000 + year, month - 1, 1);
const now = new Date();
return expiry < now ? { expired: true } : null;
}
function luhnCheckValidator(control: AbstractControl): ValidationErrors | null {
const value = (control.value as string).replace(/\s/g, '');
if (!value) return null;
// Luhn algorithm to validate card number checksum
let sum = 0;
let isEven = false;
for (let i = value.length - 1; i >= 0; i--) {
let digit = parseInt(value[i]);
if (isEven) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
isEven = !isEven;
}
return sum % 10 !== 0 ? { invalidCard: true } : null;
}
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="field">
<label for="cardNumber">Card number</label>
<input
id="cardNumber"
formControlName="cardNumber"
placeholder="1234 5678 9012 3456"
[attr.aria-invalid]="isInvalid('cardNumber')"
[attr.aria-describedby]="isInvalid('cardNumber') ? 'cardNumber-error' : null"
/>
@if (isInvalid('cardNumber')) {
<span id="cardNumber-error" role="alert">
@if (form.get('cardNumber')?.errors?.['required']) { Card number is required }
@if (form.get('cardNumber')?.errors?.['invalidCard']) { Invalid card number }
</span>
}
</div>
<div class="row">
<div class="field">
<label for="expiry">Expiry (MM/YY)</label>
<input id="expiry" formControlName="expiry" placeholder="12/28" />
@if (isInvalid('expiry')) {
<span role="alert">
@if (form.get('expiry')?.errors?.['expired']) { Card is expired }
</span>
}
</div>
<div class="field">
<label for="cvc">CVC</label>
<input id="cvc" formControlName="cvc" placeholder="123" maxlength="4" />
</div>
</div>
<button type="submit" [disabled]="form.invalid || isSubmitting()">
{{ isSubmitting() ? 'Processing...' : 'Pay now' }}
</button>
</form>
`,
})
export class CheckoutFormComponent {
private fb = inject(FormBuilder);
readonly isSubmitting = signal(false);
form = this.fb.group({
cardNumber: ['', [
Validators.required,
Validators.pattern(/^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/),
luhnCheckValidator,
]],
expiry: ['', [
Validators.required,
Validators.pattern(/^\d{2}\/\d{2}$/),
expiryDateValidator,
]],
cvc: ['', [
Validators.required,
Validators.pattern(/^\d{3,4}$/),
]],
});
isInvalid(controlName: string): boolean {
const control = this.form.get(controlName);
return !!(control?.invalid && (control.dirty || control.touched));
}
async onSubmit() {
if (this.form.invalid) return;
this.isSubmitting.set(true);
// ... process payment
}
}
Services with RxJS
Create an HttpService that adds auth headers, handles token refresh,
and retries failed requests with exponential backoff.
// src/app/core/services/api.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import {
Observable, throwError, BehaviorSubject, switchMap, filter, take, catchError, retry
} from 'rxjs';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private auth = inject(AuthService);
private isRefreshing = false;
private refreshTokenSubject = new BehaviorSubject<string | null>(null);
get<T>(path: string): Observable<T> {
return this.http.get<T>(`/api${path}`, { headers: this.authHeaders }).pipe(
catchError(err => this.handleError(err, () => this.get<T>(path))),
retry({ count: 2, delay: (_, attempt) => new Promise(res => setTimeout(res, 1000 * attempt)) }),
);
}
private get authHeaders(): HttpHeaders {
return new HttpHeaders({ Authorization: `Bearer ${this.auth.accessToken()}` });
}
private handleError<T>(error: any, retryFn: () => Observable<T>): Observable<T> {
if (error.status !== 401) return throwError(() => error);
if (this.isRefreshing) {
// Wait for ongoing refresh, then retry
return this.refreshTokenSubject.pipe(
filter(token => token !== null),
take(1),
switchMap(() => retryFn()),
);
}
this.isRefreshing = true;
this.refreshTokenSubject.next(null);
return this.auth.refreshToken().pipe(
switchMap(newToken => {
this.isRefreshing = false;
this.refreshTokenSubject.next(newToken);
return retryFn();
}),
catchError(refreshError => {
this.isRefreshing = false;
this.auth.logout();
return throwError(() => refreshError);
}),
);
}
}
Testing Angular Components
Write unit tests for the ProductList component
using Jest and Angular Testing Library.
// src/app/features/products/product-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { ProductListComponent } from './product-list.component';
describe('ProductListComponent', () => {
let fixture: ComponentFixture<ProductListComponent>;
let httpMock: HttpTestingController;
const mockProducts = [
{ id: '1', name: 'Widget', price: 2999, category: 'Tools', inStock: true },
{ id: '2', name: 'Gadget', price: 4999, category: 'Electronics', inStock: false },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('shows loading state initially', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Loading');
httpMock.expectOne('/api/products').flush(mockProducts);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Loading');
});
it('displays products after loading', async () => {
fixture.detectChanges();
httpMock.expectOne('/api/products').flush(mockProducts);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Widget');
expect(fixture.nativeElement.textContent).toContain('Gadget');
});
it('filters by in-stock when checkbox is checked', async () => {
fixture.detectChanges();
httpMock.expectOne('/api/products').flush(mockProducts);
fixture.detectChanges();
const checkbox = fixture.nativeElement.querySelector('input[type="checkbox"]');
checkbox.click();
fixture.detectChanges();
// Only in-stock items should show
expect(fixture.nativeElement.textContent).toContain('Widget');
expect(fixture.nativeElement.textContent).not.toContain('Gadget');
});
});
For integration with backend APIs including authentication, see the authentication guide. For TypeScript patterns that apply across Angular, React, and Vue, see the TypeScript guide. The Claude Skills 360 bundle includes Angular skill sets for Signals patterns, NgRx store design, and Angular Material theming. Start with the free tier to try Angular component generation.