angular-ui-patterns

CategoryData
AuthorAgentic Awesome Skills 社区
LicenseMIT
Rating4.30/5
Uses13.6K

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

code
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

code
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

  • angular-state-management: Use Signal stores for state
  • angular: Apply modern patterns (Signals, @defer)
  • testing-patterns: Test all UI states

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.
Join our Telegram