--- name: angular-ui-patterns description: Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states. risk: safe source: self --- # Angular UI Patterns ## Core Principles 1. **Never show stale UI** - Loading states only when actually loading 2. **Always surface errors** - Users must know when something fails 3. **Optimistic updates** - Make the UI feel instant 4. **Progressive disclosure** - Use `@defer` to show content as available 5. **Graceful degradation** - Partial data is better than no data --- ## Loading State Patterns ### The Golden Rule **Show loading indicator ONLY when there's no data to display.** ```typescript @Component({ template: ` @if (error()) { } @else if (loading() && !items().length) { } @else if (!items().length) { } @else { } `, }) export class ItemListComponent { private store = inject(ItemStore); items = this.store.items; loading = this.store.loading; error = this.store.error; } ``` ### Loading State Decision Tree ``` Is there an error? → Yes: Show error state with retry option → No: Continue Is it loading AND we have no data? → Yes: Show loading indicator (spinner/skeleton) → No: Continue Do we have data? → Yes, with items: Show the data → Yes, but empty: Show empty state → No: Show loading (fallback) ``` ### Skeleton vs Spinner | Use Skeleton When | Use Spinner When | | -------------------- | --------------------- | | Known content shape | Unknown content shape | | List/card layouts | Modal actions | | Initial page load | Button submissions | | Content placeholders | Inline operations | --- ## Control Flow Patterns ### @if/@else for Conditional Rendering ```html @if (user(); as user) { Welcome, {{ user.name }} } @else if (loading()) { } @else { Sign In } ``` ### @for with Track ```html @for (item of items(); track item.id) { } @empty { } ``` ### @defer for Progressive Loading ```html @defer (on viewport) { } @placeholder {
} @loading (minimum 200ms) { } @error { } ``` --- ## Error Handling Patterns ### Error Handling Hierarchy ``` 1. Inline error (field-level) → Form validation errors 2. Toast notification → Recoverable errors, user can retry 3. Error banner → Page-level errors, data still partially usable 4. Full error screen → Unrecoverable, needs user action ``` ### Always Show Errors **CRITICAL: Never swallow errors silently.** ```typescript // CORRECT - Error always surfaced to user @Component({...}) export class CreateItemComponent { private store = inject(ItemStore); private toast = inject(ToastService); async create(data: CreateItemDto) { try { await this.store.create(data); this.toast.success('Item created successfully'); this.router.navigate(['/items']); } catch (error) { console.error('createItem failed:', error); this.toast.error('Failed to create item. Please try again.'); } } } // WRONG - Error silently caught async create(data: CreateItemDto) { try { await this.store.create(data); } catch (error) { console.error(error); // User sees nothing! } } ``` ### Error State Component Pattern ```typescript @Component({ selector: "app-error-state", standalone: true, imports: [NgOptimizedImage], template: `

{{ title() }}

{{ message() }}

@if (retry.observed) { }
`, }) export class ErrorStateComponent { title = input("Something went wrong"); message = input("An unexpected error occurred"); retry = output(); } ``` --- ## Button State Patterns ### Button Loading State ```html ``` ### Disable During Operations **CRITICAL: Always disable triggers during async operations.** ```typescript // CORRECT - Button disabled while loading @Component({ template: ` ` }) export class SaveButtonComponent { saving = signal(false); async save() { this.saving.set(true); try { await this.service.save(); } finally { this.saving.set(false); } } } // WRONG - User can click multiple times ``` --- ## Empty States ### Empty State Requirements Every list/collection MUST have an empty state: ```html @for (item of items(); track item.id) { } @empty { } ``` ### Contextual Empty States ```typescript @Component({ selector: "app-empty-state", template: `

{{ title() }}

{{ description() }}

@if (actionLabel()) { }
`, }) export class EmptyStateComponent { icon = input("inbox"); title = input.required(); description = input(""); actionLabel = input(null); action = output(); } ``` --- ## Form Patterns ### Form with Loading and Validation ```typescript @Component({ template: `
@if (isFieldInvalid("name")) { {{ getFieldError("name") }} }
@if (isFieldInvalid("email")) { {{ getFieldError("email") }} }
`, }) export class UserFormComponent { private fb = inject(FormBuilder); submitting = signal(false); form = this.fb.group({ name: ["", [Validators.required, Validators.minLength(2)]], email: ["", [Validators.required, Validators.email]], }); isFieldInvalid(field: string): boolean { const control = this.form.get(field); return control ? control.invalid && control.touched : false; } getFieldError(field: string): string { const control = this.form.get(field); if (control?.hasError("required")) return "This field is required"; if (control?.hasError("email")) return "Invalid email format"; if (control?.hasError("minlength")) return "Too short"; return ""; } async onSubmit() { if (this.form.invalid) return; this.submitting.set(true); try { await this.service.submit(this.form.value); this.toast.success("Submitted successfully"); } catch { this.toast.error("Submission failed"); } finally { this.submitting.set(false); } } } ``` --- ## Dialog/Modal Patterns ### Confirmation Dialog ```typescript // dialog.service.ts @Injectable({ providedIn: 'root' }) export class DialogService { private dialog = inject(Dialog); // CDK Dialog or custom async confirm(options: { title: string; message: string; confirmText?: string; cancelText?: string; }): Promise { const dialogRef = this.dialog.open(ConfirmDialogComponent, { data: options, }); return await firstValueFrom(dialogRef.closed) ?? false; } } // Usage async deleteItem(item: Item) { const confirmed = await this.dialog.confirm({ title: 'Delete Item', message: `Are you sure you want to delete "${item.name}"?`, confirmText: 'Delete', }); if (confirmed) { await this.store.delete(item.id); } } ``` --- ## Anti-Patterns ### Loading States ```typescript // WRONG - Spinner when data exists (causes flash on refetch) @if (loading()) { } // CORRECT - Only show loading without data @if (loading() && !items().length) { } ``` ### Error Handling ```typescript // WRONG - Error swallowed try { await this.service.save(); } catch (e) { console.log(e); // User has no idea! } // CORRECT - Error surfaced try { await this.service.save(); } catch (e) { console.error("Save failed:", e); this.toast.error("Failed to save. Please try again."); } ``` ### Button States ```html ``` --- ## UI State Checklist Before completing any UI component: ### UI States - [ ] Error state handled and shown to user - [ ] Loading state shown only when no data exists - [ ] Empty state provided for collections (`@empty` block) - [ ] Buttons disabled during async operations - [ ] Buttons show loading indicator when appropriate ### Data & Mutations - [ ] All async operations have error handling - [ ] All user actions have feedback (toast/visual) - [ ] Optimistic updates rollback on failure ### Accessibility - [ ] Loading states announced to screen readers - [ ] Error messages linked to form fields - [ ] Focus management after state changes --- ## Integration with Other Skills - **angular-state-management**: Use Signal stores for state - **angular**: Apply modern patterns (Signals, @defer) - **testing-patterns**: Test all UI states