angular-agent-skill
Angular Development
Expert guidance for building scalable Angular applications using modern APIs: signals, standalone components, built-in control flow, and strict TypeScript.
Core Architecture
Project Structure
src/
└── app/
├── core/ # Non-business features (layout, auth, guards)
├── features/ # Business domain features
├── shared/ # Reusable "dumb" components, pipes, directives
├── services/ # Global and feature-specific services
├── interfaces/ # TypeScript interfaces
├── types/ # Type definitions, DTOs (requests/, responses/)
├── enums/ # Enums
├── utils/ # Pure utility functions
├── app.component.ts
├── app.config.ts
└── app.routes.ts
Feature folders follow: pages/ (routed), components/ (non-routed), guards/, feature.routes.ts.
Coding Standards
- Single quotes, 2-space indent,
constby default - Kebab-case file names with Angular suffixes (
.component.ts,.service.ts) - Strict typing, never use
any— define explicit types - Optional chaining (
?.) and nullish coalescing (??) - Template literals for string interpolation
- Always declare methods and properties explicitly as
publicorprivate— never rely on implicit public - Prefix private properties with underscore:
private _state,private readonly _http = inject(HttpClient) - Use
private readonlyfor all DI injections:private readonly _http = inject(HttpClient) - File structure: imports, class, properties, lifecycle, public methods, private methods
Signals (State Management)
Use Angular's signal system as the primary state management approach.
Signal Primitives
// Writable signal
count = signal(0);
count.set(3);
count.update(v => v + 1);
// Read-only exposure
private readonly _count = signal(0);
readonly count = this._count.asReadonly();
// Computed (lazy, memoized, read-only)
doubleCount = computed(() => this.count() * 2);
// Custom equality for objects
data = signal(['test'], { equal: (a, b) => a.length === b.length });
Signal Inputs, Outputs & Queries
Use function-based APIs instead of decorators:
// Inputs
value = input(0); // InputSignal<number>
name = input<string>(); // InputSignal<string | undefined>
label = input.required<string>(); // Required, no undefined
disabled = input(false, { transform: booleanAttribute });
// Outputs
closed = output<void>(); // OutputEmitterRef<void>
valueChanged = output<number>();
this.valueChanged.emit(42);
// Model inputs (two-way binding)
checked = model(false); // ModelSignal<boolean>
// Parent: <my-comp [(checked)]="isChecked" />
// Queries
header = viewChild(HeaderComponent); // Signal<HeaderComponent | undefined>
items = viewChildren(ItemComponent); // Signal<readonly ItemComponent[]>
toggle = contentChild.required(ToggleComponent); // Signal<ToggleComponent>
linkedSignal
Writable signal that resets when dependencies change. Use for selections that depend on changing data:
// Shorthand
selectedOption = linkedSignal(() => this.options()[0]);
// Full form — preserves selection across data changes
selectedOption = linkedSignal({
source: this.options,
computation: (newOptions, previous) =>
newOptions.find(o => o.id === previous?.value.id) ?? newOptions[0],
});
resource / httpResource
Integrate async data into signals. Experimental but ready for use:
userResource = resource({
params: () => ({ id: this.userId() }),
loader: ({ params, abortSignal }) =>
fetch(`/api/users/${params.id}`, { signal: abortSignal }).then(r => r.json()),
});
// Access: userResource.value(), .status(), .isLoading(), .error(), .hasValue()
// Actions: userResource.reload(), .set(), .update()
effect
For side effects only — logging, localStorage sync, DOM interop. Never use for state propagation (use computed or linkedSignal instead):
constructor() {
effect((onCleanup) => {
const user = this.currentUser();
const timer = setTimeout(() => console.log(user), 1000);
onCleanup(() => clearTimeout(timer));
});
}
RxJS Interop
// Observable → Signal
counter = toSignal(this.counter$, { initialValue: 0 });
// Use requireSync: true for BehaviorSubject
// Signal → Observable
query$ = toObservable(this.query);
For detailed signal patterns, see references/signals.md.
Templates
Built-in Control Flow
Always use the built-in block syntax. Never use *ngIf, *ngFor, *ngSwitch:
@if (user(); as user) {
<h1>{{ user.name }}</h1>
} @else {
<p>Loading...</p>
}
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>No items found.</li>
}
@switch (role()) {
@case ('admin') { <admin-panel /> }
@case ('editor') { <editor-panel /> }
@default { <viewer-panel /> }
}
@for requires track. Use a unique identifier; use $index only for static lists. Available context: $index, $first, $last, $even, $odd, $count.
Deferrable Views
Reduce initial bundle size by deferring non-critical content:
@defer (on viewport; prefetch on idle) {
<heavy-chart [data]="chartData()" />
} @placeholder (minimum 200ms) {
<div class="skeleton"></div>
} @loading (after 100ms; minimum 500ms) {
<spinner />
} @error {
<p>Failed to load chart.</p>
}
Triggers: on idle, on viewport, on interaction, on hover, on timer(500ms), on immediate, when condition.
For detailed template patterns, see references/templates.md.
Components & DI
Standalone by Default
All components are standalone by default (Angular 19+). Import dependencies directly:
@Component({
selector: 'app-user-card',
imports: [DatePipe, RouterLink],
templateUrl: './user-card.component.html',
styleUrl: './user-card.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
user = input.required<User>();
selected = output<void>();
}
Dependency Injection
Use inject() function, not constructor injection. Always use private readonly with underscore prefix:
export class UserService {
private readonly _http = inject(HttpClient);
private readonly _config = inject(ConfigService);
}
Services use providedIn: 'root' for tree-shakeable singletons.
HTTP
Convert observables to promises with firstValueFrom:
public async getUser(id: string): Promise<User> {
return firstValueFrom(
this._http.get<User>(`/api/users/${id}`)
);
}
Configure with functional API:
provideHttpClient(
withInterceptors([authInterceptor]),
withFetch(), // Use Fetch API, required for SSR streaming
)
Functional Interceptors
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (token) {
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(req);
};
Lifecycle & Cleanup
- Use
afterNextRender()/afterRenderEffect()for DOM operations (browser-only) - Prefer
DestroyRef+takeUntilDestroyed()for subscription cleanup:
// In constructor (injection context) — no argument needed
this.route.params.pipe(takeUntilDestroyed()).subscribe(/*...*/);
// Outside constructor — pass DestroyRef
private readonly _destroyRef = inject(DestroyRef);
public ngOnInit() {
this.obs$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe(/*...*/);
}
- Render phases:
earlyRead→write→mixedReadWrite→read
Routing
Use functional guards and resolvers. Lazy-load feature routes:
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/pages/dashboard.component'),
canActivate: [() => inject(AuthService).isAuthenticated()],
},
{
path: 'user/:id',
loadComponent: () => import('./features/user/pages/user-detail.component'),
resolve: { user: (route: ActivatedRouteSnapshot) => inject(UserService).getUser(route.paramMap.get('id')!) },
},
];
Enable withComponentInputBinding() to automatically bind route params, query params, and resolve data to component inputs:
// app.config.ts
provideRouter(routes, withComponentInputBinding(), withViewTransitions())
// Component — 'id' auto-bound from route param ':id', 'user' from resolve
export class UserDetailComponent {
id = input<string>();
user = input<User>();
}
App Configuration
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(withInterceptors([authInterceptor]), withFetch()),
provideAnimationsAsync(),
provideClientHydration(withEventReplay()),
// provideExperimentalZonelessChangeDetection(), // Zoneless (experimental)
],
};
Performance
ChangeDetectionStrategy.OnPushon all componentstrackwith unique IDs in@forloops@deferfor below-fold and heavy componentsNgOptimizedImagefor all images withpriorityon LCP image- Pure pipes for repeated template computations
- Signals automatically optimize re-renders with granular tracking
takeUntilDestroyed()to prevent subscription memory leaks
Accessibility (a11y)
Use semantic HTML first. Add ARIA attributes only when native elements aren't sufficient. Always bind ARIA attributes with [attr.aria-*] (not [aria-*]) since they have no DOM properties.
@angular/aria — Headless Accessible Components
For common interactive UI patterns, use @angular/aria (npm install @angular/aria). These headless directives implement WAI-ARIA patterns with full keyboard navigation, ARIA attributes, and focus management built in — you provide HTML structure and CSS styling.
Available components:
| Need | Component | Import |
|---|---|---|
| Collapsible sections | Accordion | @angular/aria/accordion |
| Tabbed panels | Tabs | @angular/aria/tabs |
| Selection list | Listbox | @angular/aria/listbox |
| Dropdown (<20 options) | Select | @angular/aria/combobox + listbox |
| Multi-select dropdown | Multiselect | @angular/aria/combobox + listbox |
| Searchable dropdown | Autocomplete | @angular/aria/combobox + listbox |
| Context/action menu | Menu | @angular/aria/menu |
| Navigation bar | Menubar | @angular/aria/menu |
| Grouped actions | Toolbar | @angular/aria/toolbar |
| Interactive table/calendar | Grid | @angular/aria/grid |
| Hierarchical data | Tree | @angular/aria/tree |
All components are headless (bring your own styles), support RTL, use Angular signals for state, and import directly into standalone components.
// Example: Accessible tabs with @angular/aria
import { Tabs, TabList, Tab, TabPanel, TabContent } from '@angular/aria/tabs';
@Component({
imports: [Tabs, TabList, Tab, TabPanel, TabContent],
template: `
<div ngTabs>
<div ngTabList selectionMode="follow" selectedTab="general">
<div ngTab value="general">General</div>
<div ngTab value="security">Security</div>
</div>
<div ngTabPanel [preserveContent]="true" value="general">
<ng-template ngTabContent>General settings</ng-template>
</div>
<div ngTabPanel [preserveContent]="true" value="security">
<ng-template ngTabContent>Security settings</ng-template>
</div>
</div>
`,
})
For detailed API reference, inputs, keyboard interactions, and examples for all components, see references/angular-aria.md.
CDK a11y Module
For lower-level accessibility utilities (focus traps, focus monitoring, screen reader announcements, custom keyboard navigation), use @angular/cdk/a11y. Services are providedIn: 'root' — no module import needed:
import { CdkTrapFocus, CdkMonitorFocus, CdkAriaLive } from '@angular/cdk/a11y';
Focus Management
Use CdkTrapFocus for modals/dialogs to trap Tab focus. Use cdkFocusInitial to mark the initial focus target:
<div role="dialog" aria-modal="true" [attr.aria-labelledby]="titleId"
cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
<h2 [id]="titleId">{{ title() }}</h2>
<input cdkFocusInitial />
<button (click)="close()">Close</button>
</div>
Use FocusMonitor to detect how an element received focus and show keyboard-only focus rings:
private readonly _focusMonitor = inject(FocusMonitor);
// _focusMonitor.monitor(el) → Observable<FocusOrigin>
// _focusMonitor.focusVia(el, 'keyboard') → programmatic focus with origin
Screen Reader Announcements
Use LiveAnnouncer for dynamic status updates:
private readonly _liveAnnouncer = inject(LiveAnnouncer);
this._liveAnnouncer.announce('Item saved', 'polite'); // Waits for current speech
this._liveAnnouncer.announce('Error occurred', 'assertive'); // Interrupts
Or CdkAriaLive in templates: <div [cdkAriaLive]="'polite'">{{ status() }}</div>
Keyboard Navigation
FocusKeyManager — roving tabindex pattern (moves DOM focus between items):
keyManager = new FocusKeyManager(this.items()).withWrap().withVerticalOrientation();
// Items implement: focus(), disabled?, getLabel?()
ActiveDescendantKeyManager — uses aria-activedescendant (focus stays on host, e.g. combobox):
keyManager = new ActiveDescendantKeyManager(this.options()).withWrap().withTypeAhead();
// Items implement: setActiveStyles(), setInactiveStyles(), disabled?
// Host: [attr.aria-activedescendant]="keyManager.activeItem?.id"
Accessible Forms
<label for="email">Email</label>
<input id="email" type="email"
[attr.aria-invalid]="emailInvalid() ? 'true' : null"
[attr.aria-describedby]="emailInvalid() ? 'email-error' : null"
[attr.aria-required]="'true'" />
@if (emailInvalid()) {
<span id="email-error" role="alert">Please enter a valid email.</span>
}
For detailed CDK a11y patterns and full API reference, see references/accessibility.md.
Security
- Never use
innerHTML— rely on Angular's built-in sanitization - Sanitize dynamic content with Angular's DomSanitizer when necessary
Testing
- Arrange-Act-Assert pattern
- Test services, components, and utilities with high coverage
SSR / Hybrid Rendering
Configure per-route rendering in app.routes.server.ts:
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Client },
{ path: 'about', renderMode: RenderMode.Prerender },
{ path: 'profile', renderMode: RenderMode.Server },
];
Use afterNextRender() for browser-only code. @defer blocks render @placeholder on server.
References
- references/signals.md — Detailed signal API reference, patterns for resource, linkedSignal, effect cleanup, and RxJS interop
- references/templates.md — Control flow details, @defer triggers, SSR considerations, and template best practices
- references/angular-aria.md —
@angular/ariaheadless accessible components: Accordion, Tabs, Listbox, Select, Multiselect, Autocomplete, Combobox, Menu, Menubar, Toolbar, Grid, Tree — with full API, inputs, keyboard interactions, and examples - references/accessibility.md — CDK a11y utilities: FocusTrap, FocusMonitor, LiveAnnouncer, AriaDescriber, KeyManagers, InteractivityChecker, InputModalityDetector, HighContrastModeDetector, and accessible component patterns