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 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
< app-header /> < app-hero-section />
@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 : `
{{ title() }}
{{ message() }}
@if (retry.observed) { }` , } ) 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()) {
Saving... } @else { Save } ` } ) 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 : `
{{ title() }}
{{ description() }}
@if (actionLabel()) { }` , } ) 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 : `
` , } ) 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
< button (click) = " submit() "
Submit </ button
- <
- 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.