Back to skills
extension
Category: Development & EngineeringNo API key required

"feedback-states"

Feedback state patterns: loading indicators, error handling UI, success confirmations, disabled states, skeleton views. Use when adding loading spinners, error alerts, success feedback, or managing empty/error/loading view states. Triggers: ProgressView, loading, error, alert, empty state, skeleton, disabled, feedback.

personAuthor: jakexiaohubgithub

Feedback States

MANDATORY: Every Async View Must Handle All 4 States

Every view displaying async data MUST use a switch on Loadable<T> covering ALL 4 states. This is not optional.

switch viewModel.items {
case .notInitiated, .loading:
    ProgressView("Loading...")
case .success(let items) where items.isEmpty:
    ContentUnavailableView("No Items Yet", systemImage: "tray",
        description: Text("Tap + to create your first item"))
case .success(let items):
    List(items) { item in ItemRow(item: item) }
case .failure(let error):
    ContentUnavailableView {
        Label("Load Failed", systemImage: "exclamationmark.triangle")
    } description: {
        Text(error.localizedDescription)
    } actions: {
        Button("Retry") { Task { await viewModel.loadItems() } }
    }
}

Rules:

  • NEVER use if let to unwrap only the success case — all 4 states must be handled
  • Empty state MUST use ContentUnavailableView with an action button
  • Error state MUST include a retry button
  • Loading state MUST show ProgressView

Upload / Mutation State Handling

Every mutation button (save, delete, upload, send) MUST:

  1. Disable the trigger while in-progress
  2. Show an inline spinner replacing the button label
  3. Provide success/failure feedback after completion
Button {
    Task { await viewModel.save() }
} label: {
    if viewModel.saveState.isLoading {
        ProgressView()
            .controlSize(.small)
    } else {
        Text("Save")
    }
}
.disabled(viewModel.saveState.isLoading)

LOADING PATTERNS:

  1. Inline button spinner (action on single element):
Button {
    Task { await save() }
} label: {
    if saveState.isLoading {
        ProgressView()
            .controlSize(.small)
    } else {
        Text("Save")
    }
}
.disabled(saveState.isLoading)
  1. Full-screen loading (initial data load using Loadable):
switch viewModel.items {
case .notInitiated, .loading:
    ProgressView("Loading...")
case .success(let items):
    ContentListView(items: items)
case .failure(let error):
    ErrorView(error: error) {
        Task { await viewModel.loadItems() }
    }
}
  1. Skeleton loading (content placeholders):
ForEach(Item.sampleData) { item in
    ItemRow(item: item)
}
.redacted(reason: .placeholder)
  1. Pull-to-refresh (list content):
List { ... }
    .refreshable { await viewModel.refresh() }
  1. Overlay loading (blocking operation):
.overlay {
    if operationState.isLoading {
        ZStack {
            Color.black.opacity(0.3)
            ProgressView()
                .controlSize(.large)
                .tint(.white)
        }
        .ignoresSafeArea()
    }
}

LOADING RULES:

  • Show indicator for operations > 300ms.
  • ALWAYS disable the triggering button while loading (prevents double-taps).
  • Never block the entire UI for a partial operation — use inline spinner.
  • Match loading style to scope: button-level → inline, screen-level → full-screen.
  • Use Loadable<T> for all async state — never var isLoading: Bool + var errorMessage: String?.

ERROR HANDLING UI:

  1. Inline validation (below form fields):
if let error = emailError {
    HStack(spacing: 4) {
        Image(systemName: "exclamationmark.circle.fill")
        Text(error)
    }
    .font(AppTheme.Fonts.caption)
    .foregroundStyle(AppTheme.Colors.error)
}
  1. Alert for blocking errors (require user acknowledgment):
.alert("Error", isPresented: $showError) {
    Button("Retry") { Task { await retry() } }
    Button("Cancel", role: .cancel) { }
} message: {
    Text(errorMessage)
}
  1. Banner for non-blocking errors (dismissible):
if let error = bannerError {
    HStack {
        Image(systemName: "exclamationmark.triangle.fill")
            .foregroundStyle(AppTheme.Colors.warning)
        Text(error)
            .font(AppTheme.Fonts.subheadline)
        Spacer()
        Button("Dismiss") { bannerError = nil }
            .font(AppTheme.Fonts.caption)
    }
    .padding(AppTheme.Spacing.small)
    .background(.orange.opacity(0.1))
    .clipShape(RoundedRectangle(cornerRadius: 8))
    .padding(.horizontal, AppTheme.Spacing.medium)
}

ERROR HANDLING RULES:

  • Inline validation: show immediately as user types or on field blur.
  • Alert: use for errors that block progress (network failure, permission denied).
  • Banner: use for non-critical errors (sync failed, partial data).
  • ALWAYS provide a retry path — never leave users stuck.
  • Error messages: describe what happened + what the user can do.

SUCCESS FEEDBACK:

  • Haptic: UINotificationFeedbackGenerator().notificationOccurred(.success).
  • Visual: brief animation (checkmark, scale bounce, color flash).
  • NEVER use modal alert for success — too disruptive.
  • Subtle confirmation: toast, inline checkmark, or haptic alone.
// Brief success animation
withAnimation(.spring(response: 0.3)) {
    showSuccess = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
    withAnimation { showSuccess = false }
}

DISABLED STATE:

  • .disabled(condition) — SwiftUI auto-handles opacity reduction.
  • Always explain WHY something is disabled (tooltip, caption text, or label).
  • Example: "Fill in all required fields to continue" below a disabled button.
  • Don't hide actions — show them disabled with explanation.

NETWORK/SYSTEM ERROR PATTERN (ViewModel with Loadable):

@MainActor @Observable
class ItemViewModel {
    var items: Loadable<[Item]> = .notInitiated

    func loadItems() async {
        items = .loading
        do {
            items = .success(try await fetchItems())
        } catch {
            items = .failure(error)
        }
    }

    var userFacingError: String? {
        if case .failure = items {
            return "Couldn't load items. Pull to refresh to try again."
        }
        return nil
    }
}

EMPTY VS ERROR VS LOADING (switch on Loadable):

switch viewModel.items {
case .notInitiated, .loading:
    ProgressView("Loading...")
case .success(let items) where items.isEmpty:
    ContentUnavailableView("No Items Yet", systemImage: "tray",
        description: Text("Tap + to create your first item"))
case .success(let items):
    List(items) { item in ItemRow(item: item) }
case .failure(let error):
    ContentUnavailableView {
        Label("Load Failed", systemImage: "exclamationmark.triangle")
    } description: {
        Text(error.localizedDescription)
    } actions: {
        Button("Retry") { Task { await viewModel.loadItems() } }
    }
}
  • These are four distinct states — never conflate them.
  • Loadable<T> makes each state explicit and compiler-enforced via switch.