angular-ui-patterns

现代Angular UI模式:加载状态、错误处理与数据展示。适用于构建UI组件、处理异步数据或管理组件状态。

查看详情
name:angular-ui-patternsdescription: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:safesource:self

Angular UI Patterns

Core Principles

  • Never show stale UI - Loading states only when actually loading

  • Always surface errors - Users must know when something fails

  • Optimistic updates - Make the UI feel instant

  • Progressive disclosure - Use @defer to show content as available

  • 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.

    @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 WhenUse Spinner When
    Known content shapeUnknown content shape
    List/card layoutsModal actions
    Initial page loadButton submissions
    Content placeholdersInline operations


    Control Flow Patterns

    @if/@else for Conditional Rendering

    @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

    @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

    <!-- 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
  • Toast notification → Recoverable errors, user can retry

  • Error banner → Page-level errors, data still partially usable

  • Full error screen → Unrecoverable, needs user action
  • Always Show Errors

    CRITICAL: Never swallow errors silently.

    // 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

    @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

    <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.

    // 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:

    @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

    @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

    @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

    // 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

    // 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

    // 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

    <!-- 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