git:20260708.a07936c to git:20260905.f2e5074

2 added, 498 removed. Audit A to A.

---
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
date_added: "2026-02-27"
---
# 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()) {
- <app-error-state [error]="error()" (retry)="load()" />
- } @else if (loading() && !items().length) {
- <app-skeleton-list />
- } @else if (!items().length) {
- <app-empty-state message="No items found" />
- } @else {
- <app-item-list [items]="items()" />
- }
- `,
- })
- 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) {
- <span>Welcome, {{ user.name }}</span>
- } @else if (loading()) {
- <app-spinner size="small" />
- } @else {
- <a routerLink="/login">Sign In</a>
- }
- ```
-
- ### @for with Track
-
- ```html
- @for (item of items(); track item.id) {
- <app-item-card [item]="item" (delete)="remove(item.id)" />
- } @empty {
- <app-empty-state
- icon="inbox"
- message="No items yet"
- actionLabel="Create Item"
- (action)="create()"
- />
- }
- ```
-
- ### @defer for Progressive Loading
-
- ```html
- <!-- Critical content loads immediately -->
- <app-header />
- <app-hero-section />
-
- <!-- Non-critical content deferred -->
- @defer (on viewport) {
- <app-comments [postId]="postId()" />
- } @placeholder {
- <div class="h-32 bg-gray-100 animate-pulse"></div>
- } @loading (minimum 200ms) {
- <app-spinner />
- } @error {
- <app-error-state message="Failed to load comments" />
- }
- ```
-
- ---
-
- ## 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: `
- <div class="error-state">
- <img ngSrc="/assets/error-icon.svg" width="64" height="64" alt="" />
- <h3>{{ title() }}</h3>
- <p>{{ message() }}</p>
- @if (retry.observed) {
- <button (click)="retry.emit()" class="btn-primary">Try Again</button>
- }
- </div>
- `,
- })
- export class ErrorStateComponent {
- title = input("Something went wrong");
- message = input("An unexpected error occurred");
- retry = output<void>();
- }
- ```
-
- ---
-
- ## Button State Patterns
-
- ### Button Loading State
-
- ```html
- <button
- (click)="handleSubmit()"
- [disabled]="isSubmitting() || !form.valid"
- class="btn-primary"
- >
- @if (isSubmitting()) {
- <app-spinner size="small" class="mr-2" />
- Saving... } @else { Save Changes }
- </button>
- ```
-
- ### Disable During Operations
-
- **CRITICAL: Always disable triggers during async operations.**
-
- ```typescript
- // CORRECT - Button disabled while loading
- @Component({
- template: `
- <button
- [disabled]="saving()"
- (click)="save()"
- >
- @if (saving()) {
- <app-spinner size="sm" /> Saving...
- } @else {
- Save
- }
- </button>
- `
- })
- 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
- <button (click)="save()">
- {{ saving() ? 'Saving...' : 'Save' }}
- </button>
- ```
-
- ---
-
- ## Empty States
-
- ### Empty State Requirements
-
- Every list/collection MUST have an empty state:
-
- ```html
- @for (item of items(); track item.id) {
- <app-item-card [item]="item" />
- } @empty {
- <app-empty-state
- icon="folder-open"
- title="No items yet"
- description="Create your first item to get started"
- actionLabel="Create Item"
- (action)="openCreateDialog()"
- />
- }
- ```
-
- ### Contextual Empty States
-
- ```typescript
- @Component({
- selector: "app-empty-state",
- template: `
- <div class="empty-state">
- <span class="icon" [class]="icon()"></span>
- <h3>{{ title() }}</h3>
- <p>{{ description() }}</p>
- @if (actionLabel()) {
- <button (click)="action.emit()" class="btn-primary">
- {{ actionLabel() }}
- </button>
- }
- </div>
- `,
- })
- export class EmptyStateComponent {
- icon = input("inbox");
- title = input.required<string>();
- description = input("");
- actionLabel = input<string | null>(null);
- action = output<void>();
- }
- ```
-
- ---
-
- ## Form Patterns
-
- ### Form with Loading and Validation
-
- ```typescript
- @Component({
- template: `
- <form [formGroup]="form" (ngSubmit)="onSubmit()">
- <div class="form-field">
- <label for="name">Name</label>
- <input
- id="name"
- formControlName="name"
- [class.error]="isFieldInvalid('name')"
- />
- @if (isFieldInvalid("name")) {
- <span class="error-text">
- {{ getFieldError("name") }}
- </span>
- }
- </div>
-
- <div class="form-field">
- <label for="email">Email</label>
- <input id="email" type="email" formControlName="email" />
- @if (isFieldInvalid("email")) {
- <span class="error-text">
- {{ getFieldError("email") }}
- </span>
- }
- </div>
-
- <button type="submit" [disabled]="form.invalid || submitting()">
- @if (submitting()) {
- <app-spinner size="sm" /> Submitting...
- } @else {
- Submit
- }
- </button>
- </form>
- `,
- })
- 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<boolean> {
- 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()) {
- <app-spinner />
- }
-
- // CORRECT - Only show loading without data
- @if (loading() && !items().length) {
- <app-spinner />
- }
- ```
-
- ### 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
- <!-- WRONG - Button not disabled during submission -->
- <button (click)="submit()">Submit</button>
-
- <!-- CORRECT - Disabled and shows loading -->
- <button (click)="submit()" [disabled]="loading()">
- @if (loading()) {
- <app-spinner size="sm" />
- } Submit
- </button>
- ```
-
- ---
-
- ## 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
+ ## Detailed Guide
- - **angular-state-management**: Use Signal stores for state
- - **angular**: Apply modern patterns (Signals, @defer)
- - **testing-patterns**: Test all UI states
+ Read [the detailed guide](references/detailed-guide.md) before executing this skill. It retains the complete procedure and reference material. Treat its safety, prerequisites, and validation requirements as mandatory. For focused work, load the relevant sections; for end-to-end work, read the guide completely.
## When to Use
This skill is applicable to execute the workflow or actions described in the overview.
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.