Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states.
Add this skill
npx mdskills install sickn33/angular-ui-patternsComprehensive UI patterns with clear decision trees, code examples, and anti-patterns for robust UX
1---2name: angular-ui-patterns3description: Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states.4risk: safe5source: self6---78# Angular UI Patterns910## Core Principles11121. **Never show stale UI** - Loading states only when actually loading132. **Always surface errors** - Users must know when something fails143. **Optimistic updates** - Make the UI feel instant154. **Progressive disclosure** - Use `@defer` to show content as available165. **Graceful degradation** - Partial data is better than no data1718---1920## Loading State Patterns2122### The Golden Rule2324**Show loading indicator ONLY when there's no data to display.**2526```typescript27@Component({28 template: `29 @if (error()) {30 <app-error-state [error]="error()" (retry)="load()" />31 } @else if (loading() && !items().length) {32 <app-skeleton-list />33 } @else if (!items().length) {34 <app-empty-state message="No items found" />35 } @else {36 <app-item-list [items]="items()" />37 }38 `,39})40export class ItemListComponent {41 private store = inject(ItemStore);4243 items = this.store.items;44 loading = this.store.loading;45 error = this.store.error;46}47```4849### Loading State Decision Tree5051```52Is there an error?53 → Yes: Show error state with retry option54 → No: Continue5556Is it loading AND we have no data?57 → Yes: Show loading indicator (spinner/skeleton)58 → No: Continue5960Do we have data?61 → Yes, with items: Show the data62 → Yes, but empty: Show empty state63 → No: Show loading (fallback)64```6566### Skeleton vs Spinner6768| Use Skeleton When | Use Spinner When |69| -------------------- | --------------------- |70| Known content shape | Unknown content shape |71| List/card layouts | Modal actions |72| Initial page load | Button submissions |73| Content placeholders | Inline operations |7475---7677## Control Flow Patterns7879### @if/@else for Conditional Rendering8081```html82@if (user(); as user) {83<span>Welcome, {{ user.name }}</span>84} @else if (loading()) {85<app-spinner size="small" />86} @else {87<a routerLink="/login">Sign In</a>88}89```9091### @for with Track9293```html94@for (item of items(); track item.id) {95<app-item-card [item]="item" (delete)="remove(item.id)" />96} @empty {97<app-empty-state98 icon="inbox"99 message="No items yet"100 actionLabel="Create Item"101 (action)="create()"102/>103}104```105106### @defer for Progressive Loading107108```html109<!-- Critical content loads immediately -->110<app-header />111<app-hero-section />112113<!-- Non-critical content deferred -->114@defer (on viewport) {115<app-comments [postId]="postId()" />116} @placeholder {117<div class="h-32 bg-gray-100 animate-pulse"></div>118} @loading (minimum 200ms) {119<app-spinner />120} @error {121<app-error-state message="Failed to load comments" />122}123```124125---126127## Error Handling Patterns128129### Error Handling Hierarchy130131```1321. Inline error (field-level) → Form validation errors1332. Toast notification → Recoverable errors, user can retry1343. Error banner → Page-level errors, data still partially usable1354. Full error screen → Unrecoverable, needs user action136```137138### Always Show Errors139140**CRITICAL: Never swallow errors silently.**141142```typescript143// CORRECT - Error always surfaced to user144@Component({...})145export class CreateItemComponent {146 private store = inject(ItemStore);147 private toast = inject(ToastService);148149 async create(data: CreateItemDto) {150 try {151 await this.store.create(data);152 this.toast.success('Item created successfully');153 this.router.navigate(['/items']);154 } catch (error) {155 console.error('createItem failed:', error);156 this.toast.error('Failed to create item. Please try again.');157 }158 }159}160161// WRONG - Error silently caught162async create(data: CreateItemDto) {163 try {164 await this.store.create(data);165 } catch (error) {166 console.error(error); // User sees nothing!167 }168}169```170171### Error State Component Pattern172173```typescript174@Component({175 selector: "app-error-state",176 standalone: true,177 imports: [NgOptimizedImage],178 template: `179 <div class="error-state">180 <img ngSrc="/assets/error-icon.svg" width="64" height="64" alt="" />181 <h3>{{ title() }}</h3>182 <p>{{ message() }}</p>183 @if (retry.observed) {184 <button (click)="retry.emit()" class="btn-primary">Try Again</button>185 }186 </div>187 `,188})189export class ErrorStateComponent {190 title = input("Something went wrong");191 message = input("An unexpected error occurred");192 retry = output<void>();193}194```195196---197198## Button State Patterns199200### Button Loading State201202```html203<button204 (click)="handleSubmit()"205 [disabled]="isSubmitting() || !form.valid"206 class="btn-primary"207>208 @if (isSubmitting()) {209 <app-spinner size="small" class="mr-2" />210 Saving... } @else { Save Changes }211</button>212```213214### Disable During Operations215216**CRITICAL: Always disable triggers during async operations.**217218```typescript219// CORRECT - Button disabled while loading220@Component({221 template: `222 <button223 [disabled]="saving()"224 (click)="save()"225 >226 @if (saving()) {227 <app-spinner size="sm" /> Saving...228 } @else {229 Save230 }231 </button>232 `233})234export class SaveButtonComponent {235 saving = signal(false);236237 async save() {238 this.saving.set(true);239 try {240 await this.service.save();241 } finally {242 this.saving.set(false);243 }244 }245}246247// WRONG - User can click multiple times248<button (click)="save()">249 {{ saving() ? 'Saving...' : 'Save' }}250</button>251```252253---254255## Empty States256257### Empty State Requirements258259Every list/collection MUST have an empty state:260261```html262@for (item of items(); track item.id) {263<app-item-card [item]="item" />264} @empty {265<app-empty-state266 icon="folder-open"267 title="No items yet"268 description="Create your first item to get started"269 actionLabel="Create Item"270 (action)="openCreateDialog()"271/>272}273```274275### Contextual Empty States276277```typescript278@Component({279 selector: "app-empty-state",280 template: `281 <div class="empty-state">282 <span class="icon" [class]="icon()"></span>283 <h3>{{ title() }}</h3>284 <p>{{ description() }}</p>285 @if (actionLabel()) {286 <button (click)="action.emit()" class="btn-primary">287 {{ actionLabel() }}288 </button>289 }290 </div>291 `,292})293export class EmptyStateComponent {294 icon = input("inbox");295 title = input.required<string>();296 description = input("");297 actionLabel = input<string | null>(null);298 action = output<void>();299}300```301302---303304## Form Patterns305306### Form with Loading and Validation307308```typescript309@Component({310 template: `311 <form [formGroup]="form" (ngSubmit)="onSubmit()">312 <div class="form-field">313 <label for="name">Name</label>314 <input315 id="name"316 formControlName="name"317 [class.error]="isFieldInvalid('name')"318 />319 @if (isFieldInvalid("name")) {320 <span class="error-text">321 {{ getFieldError("name") }}322 </span>323 }324 </div>325326 <div class="form-field">327 <label for="email">Email</label>328 <input id="email" type="email" formControlName="email" />329 @if (isFieldInvalid("email")) {330 <span class="error-text">331 {{ getFieldError("email") }}332 </span>333 }334 </div>335336 <button type="submit" [disabled]="form.invalid || submitting()">337 @if (submitting()) {338 <app-spinner size="sm" /> Submitting...339 } @else {340 Submit341 }342 </button>343 </form>344 `,345})346export class UserFormComponent {347 private fb = inject(FormBuilder);348349 submitting = signal(false);350351 form = this.fb.group({352 name: ["", [Validators.required, Validators.minLength(2)]],353 email: ["", [Validators.required, Validators.email]],354 });355356 isFieldInvalid(field: string): boolean {357 const control = this.form.get(field);358 return control ? control.invalid && control.touched : false;359 }360361 getFieldError(field: string): string {362 const control = this.form.get(field);363 if (control?.hasError("required")) return "This field is required";364 if (control?.hasError("email")) return "Invalid email format";365 if (control?.hasError("minlength")) return "Too short";366 return "";367 }368369 async onSubmit() {370 if (this.form.invalid) return;371372 this.submitting.set(true);373 try {374 await this.service.submit(this.form.value);375 this.toast.success("Submitted successfully");376 } catch {377 this.toast.error("Submission failed");378 } finally {379 this.submitting.set(false);380 }381 }382}383```384385---386387## Dialog/Modal Patterns388389### Confirmation Dialog390391```typescript392// dialog.service.ts393@Injectable({ providedIn: 'root' })394export class DialogService {395 private dialog = inject(Dialog); // CDK Dialog or custom396397 async confirm(options: {398 title: string;399 message: string;400 confirmText?: string;401 cancelText?: string;402 }): Promise<boolean> {403 const dialogRef = this.dialog.open(ConfirmDialogComponent, {404 data: options,405 });406407 return await firstValueFrom(dialogRef.closed) ?? false;408 }409}410411// Usage412async deleteItem(item: Item) {413 const confirmed = await this.dialog.confirm({414 title: 'Delete Item',415 message: `Are you sure you want to delete "${item.name}"?`,416 confirmText: 'Delete',417 });418419 if (confirmed) {420 await this.store.delete(item.id);421 }422}423```424425---426427## Anti-Patterns428429### Loading States430431```typescript432// WRONG - Spinner when data exists (causes flash on refetch)433@if (loading()) {434 <app-spinner />435}436437// CORRECT - Only show loading without data438@if (loading() && !items().length) {439 <app-spinner />440}441```442443### Error Handling444445```typescript446// WRONG - Error swallowed447try {448 await this.service.save();449} catch (e) {450 console.log(e); // User has no idea!451}452453// CORRECT - Error surfaced454try {455 await this.service.save();456} catch (e) {457 console.error("Save failed:", e);458 this.toast.error("Failed to save. Please try again.");459}460```461462### Button States463464```html465<!-- WRONG - Button not disabled during submission -->466<button (click)="submit()">Submit</button>467468<!-- CORRECT - Disabled and shows loading -->469<button (click)="submit()" [disabled]="loading()">470 @if (loading()) {471 <app-spinner size="sm" />472 } Submit473</button>474```475476---477478## UI State Checklist479480Before completing any UI component:481482### UI States483484- [ ] Error state handled and shown to user485- [ ] Loading state shown only when no data exists486- [ ] Empty state provided for collections (`@empty` block)487- [ ] Buttons disabled during async operations488- [ ] Buttons show loading indicator when appropriate489490### Data & Mutations491492- [ ] All async operations have error handling493- [ ] All user actions have feedback (toast/visual)494- [ ] Optimistic updates rollback on failure495496### Accessibility497498- [ ] Loading states announced to screen readers499- [ ] Error messages linked to form fields500- [ ] Focus management after state changes501502---503504## Integration with Other Skills505506- **angular-state-management**: Use Signal stores for state507- **angular**: Apply modern patterns (Signals, @defer)508- **testing-patterns**: Test all UI states509
Full transparency — inspect the skill content before installing.