Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states.
npx mdskills install sickn33/angular-ui-patterns@sickn33? Sign in with GitHub to claim this listing.Comprehensive 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.