ai-docs/assets/skills/swiftui-mvvm/SKILL.md
Matt Bruce 88e4402d38 fix: use tool-agnostic ~/.agents/ as default install path
Copilot, Claude, Cursor, and others all read from ~/.agents/.
The npx skills CLI handles fan-out to tool-specific directories.
2026-02-11 12:13:20 -06:00

5.4 KiB

name description globs
SwiftUI MVVM View/State separation patterns for SwiftUI with Observable stores
**/*.swift

View/State Separation (MVVM-lite)

Views should be "dumb" renderers. All business logic belongs in stores or dedicated view models.

What Belongs in State/Store

  • Business logic: Calculations, validations, rules
  • Computed properties based on data: Hints, recommendations, derived values
  • State checks: canSubmit, isLoading, hasError
  • Data transformations: Filtering, sorting, aggregations
  • Side effects: Network calls, persistence, analytics

What is Acceptable in Views

  • Pure UI layout logic: Adaptive layouts based on size class
  • Visual styling: Color selection based on state
  • @ViewBuilder sub-views: Breaking up complex layouts (keep in same file if small)
  • Accessibility labels: Combining data into accessible descriptions
  • Simple conditionals for UI: if isExpanded { ... }

Store Pattern

@Observable
@MainActor
final class FeatureStore {
    // MARK: - State
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var error: Error?
    
    // MARK: - Computed Properties (Business Logic)
    var isEmpty: Bool { items.isEmpty }
    var itemCount: Int { items.count }
    var canSubmit: Bool { !selectedItems.isEmpty && !isLoading }
    
    var filteredItems: [Item] {
        guard !searchText.isEmpty else { return items }
        return items.filter { $0.name.localizedStandardContains(searchText) }
    }
    
    // MARK: - User Input
    var searchText = ""
    var selectedItems: Set<Item.ID> = []
    
    // MARK: - Dependencies
    private let service: FeatureServiceProtocol
    
    init(service: FeatureServiceProtocol) {
        self.service = service
    }
    
    // MARK: - Actions
    func load() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            items = try await service.fetchItems()
        } catch {
            self.error = error
        }
    }
    
    func submit() async {
        guard canSubmit else { return }
        // ...
    }
}

View Pattern

struct FeatureView: View {
    @Bindable var store: FeatureStore
    
    var body: some View {
        List(store.filteredItems) { item in
            ItemRow(item: item)
        }
        .searchable(text: $store.searchText)
        .overlay {
            if store.isEmpty {
                ContentUnavailableView("No Items", systemImage: "tray")
            }
        }
        .toolbar {
            Button("Submit") {
                Task { await store.submit() }
            }
            .disabled(!store.canSubmit)
        }
        .task {
            await store.load()
        }
    }
}

Bad vs Good Examples

Validation Logic

// BAD - Business logic in view
struct MyView: View {
    @Bindable var store: FeatureStore
    
    private var isValid: Bool {
        !store.name.isEmpty && store.email.contains("@")
    }
    
    var body: some View {
        Button("Save") { }
            .disabled(!isValid)
    }
}

// GOOD - Logic in Store, view just reads
// In FeatureStore:
var isValid: Bool {
    !name.isEmpty && email.contains("@")
}

// In View:
Button("Save") { store.save() }
    .disabled(!store.isValid)

Filtering Logic

// BAD - Filtering in view
struct ListView: View {
    @Bindable var store: ListStore
    
    var filteredItems: [Item] {
        store.items.filter { $0.isActive && $0.category == selectedCategory }
    }
    
    var body: some View {
        List(filteredItems) { ... }
    }
}

// GOOD - Filtering in Store
// In ListStore:
var filteredItems: [Item] {
    items.filter { $0.isActive && $0.category == selectedCategory }
}

// In View:
List(store.filteredItems) { ... }

Error Handling

// BAD - Error formatting in view
struct ProfileView: View {
    @Bindable var store: ProfileStore
    
    var errorMessage: String? {
        guard let error = store.error else { return nil }
        if let networkError = error as? NetworkError {
            switch networkError {
            case .notFound: return "Profile not found"
            case .unauthorized: return "Please sign in"
            default: return "Something went wrong"
            }
        }
        return error.localizedDescription
    }
}

// GOOD - Error message in Store
// In ProfileStore:
var errorMessage: String? {
    guard let error else { return nil }
    return ErrorFormatter.message(for: error)
}

// In View:
if let message = store.errorMessage {
    Text(message)
}

When to Create a Separate ViewModel

Use a dedicated ViewModel (instead of a Store) when:

  1. The view has complex local state that doesn't need to persist
  2. You need to transform data from multiple stores for a single view
  3. The view has form validation with many fields
  4. You're wrapping a UIKit component that needs state management
@Observable
@MainActor
final class FormViewModel {
    // Form-specific state
    var firstName = ""
    var lastName = ""
    var email = ""
    
    // Validation
    var isValid: Bool {
        !firstName.isEmpty && !lastName.isEmpty && email.contains("@")
    }
    
    var firstNameError: String? {
        firstName.isEmpty ? "First name is required" : nil
    }
    
    // Submit creates domain object
    func createUser() -> User {
        User(firstName: firstName, lastName: lastName, email: email)
    }
}