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

229 lines
5.4 KiB
Markdown

---
name: SwiftUI MVVM
description: View/State separation patterns for SwiftUI with Observable stores
globs: ["**/*.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
```swift
@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
```swift
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
```swift
// 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
```swift
// 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
```swift
// 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
```swift
@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)
}
}
```