6.4 KiB
paths
| paths | |||||
|---|---|---|---|---|---|
|
Angular Patterns
This file extends common/patterns.md with Angular specific content.
Smart / Dumb Component Split
Smart (container) components own data fetching and state. Dumb (presentational) components receive inputs and emit outputs only — no service injection.
// Smart — owns data
@Component({ standalone: true, changeDetection: ChangeDetectionStrategy.OnPush })
export class UserPageComponent {
private userService = inject(UserService);
user = toSignal(this.userService.getUser(this.userId));
}
<!-- Dumb — pure presentation -->
<app-user-card [user]="user()" (select)="onSelect($event)" />
Service Layer
Services own all data access and business logic. Components delegate — no HttpClient in components.
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
}
Async Data with resource
Use resource() for reactive async fetching. Prefer over manual RxJS pipelines for simple data loading:
export class UserDetailComponent {
userId = input.required<string>();
userResource = resource({
request: () => ({ id: this.userId() }),
loader: ({ request }) =>
firstValueFrom(inject(UserService).getUser(request.id)),
});
}
Access state: userResource.value(), userResource.isLoading(), userResource.error(), userResource.reload().
Signal State Patterns
// Local mutable state
count = signal(0);
// Derived (never duplicated)
doubled = computed(() => this.count() * 2);
// Writable derived state that resets with source
selectedItem = linkedSignal(() => this.items()[0]);
// Bridge Observable to signal
users = toSignal(this.userService.getUsers(), { initialValue: [] });
Never store derived values in separate signals — use computed. Never use effect to sync signals — use computed or linkedSignal.
Subscription Cleanup
Use takeUntilDestroyed() for all manual subscriptions. Never use manual ngOnDestroy + Subject + takeUntil on new code.
export class UserComponent {
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.userService.updates$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(update => this.handleUpdate(update));
}
}
Routing
Route Definition
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'admin',
canMatch: [authGuard], // CanMatch prevents loading the chunk at all
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
{
path: 'users/:id',
resolve: { user: userResolver },
component: UserDetailComponent,
},
];
- Use
canMatchovercanActivatewhen the route module should not load for unauthorized users - Lazy-load all feature modules with
loadChildren - Pre-fetch data with
resolveto avoid loading states in components
Functional Guards
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isAuthenticated()
? true
: inject(Router).createUrlTree(['/login']);
};
Data Resolvers
export const userResolver: ResolveFn<User> = (route) => {
return inject(UserService).getUser(route.paramMap.get('id')!);
};
View Transitions
Enable smooth route transitions with the View Transitions API:
// app.config.ts
provideRouter(routes, withViewTransitions())
Dependency Injection Patterns
Scoped Providers
Provide services at component or route level when they should not be singletons:
@Component({
providers: [UserEditService], // scoped to this component subtree
})
export class UserEditComponent {}
InjectionToken
export const CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
// In providers:
{ provide: CONFIG, useValue: appConfig }
{ provide: CONFIG, useFactory: () => loadConfig(), deps: [] }
// Consume:
private config = inject(CONFIG);
viewProviders vs providers
providers: Available to the component and all its content childrenviewProviders: Available only to the component's own view (not projected content)
HTTP Interceptors
Use functional interceptors (v15+) for auth, error handling, and retries:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
if (!token) return next(req);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
Register in app.config.ts:
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
RxJS Operators
switchMap— search, navigation (cancels previous)mergeMap— independent parallel requestsexhaustMap— form submissions (ignores until complete)- Always handle errors with
catchError— never let streams die silently
search$ = this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.service.search(q).pipe(catchError(() => of([])))),
);
Forms
Match the project's existing form strategy. For new v21+ apps, prefer signal forms.
// Reactive Forms — standard for complex forms
export class UserFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
}
Rendering Strategies
- CSR (default): Standard SPA
- SSR + Hydration:
ng add @angular/ssr— improves FCP and SEO - SSG (Prerendering): Static pages at build time for content-heavy routes
When using SSR, avoid window, document, localStorage directly — use isPlatformBrowser or DOCUMENT token.
Accessibility
Use Angular CDK for headless, accessible components (Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid). Style ARIA attributes rather than managing them manually:
[aria-selected="true"] { background: var(--color-selected); }
Skill Reference
See skill: angular-developer for deep guidance on signals, forms, routing, DI, SSR, and accessibility patterns.