--- 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 = [] // 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) } } ```