angular-ui-patterns

Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component…

INSTALLATION
npx skills add https://github.com/sickn33/antigravity-awesome-skills --skill angular-ui-patterns
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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() &#x26;&#x26; !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

@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

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.

// 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 &#x26;&#x26; 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() &#x26;&#x26; !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 &#x26; 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.
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card