playbook/antigravity-awesome-skills/skills/swiftui-view-refactor/references/mv-patterns.md

162 lines
5.4 KiB
Markdown

# MV Patterns Reference
Distilled guidance for deciding whether a SwiftUI feature should stay as plain MV or introduce a view model.
Inspired by the user's provided source, "SwiftUI in 2025: Forget MVVM" (Thomas Ricouard), but rewritten here as a practical refactoring reference.
## Default stance
- Default to MV: views are lightweight state expressions and orchestration points.
- Prefer `@State`, `@Environment`, `@Query`, `.task`, `.task(id:)`, and `onChange` before reaching for a view model.
- Keep business logic in services, models, or domain types, not in the view body.
- Split large screens into smaller view types before inventing a view model layer.
- Avoid manual fetching or state plumbing that duplicates SwiftUI or SwiftData mechanisms.
- Test services, models, and transformations first; views should stay simple and declarative.
## When to avoid a view model
Do not introduce a view model when it would mostly:
- mirror local view state,
- wrap values already available through `@Environment`,
- duplicate `@Query`, `@State`, or `Binding`-based data flow,
- exist only because the view body is too long,
- hold one-off async loading logic that can live in `.task` plus local view state.
In these cases, simplify the view and data flow instead of adding indirection.
## When a view model may be justified
A view model can be reasonable when at least one of these is true:
- the user explicitly asks for one,
- the codebase already standardizes on a view model pattern for that feature,
- the screen needs a long-lived reference model with behavior that does not fit naturally in services alone,
- the feature is adapting a non-SwiftUI API that needs a dedicated bridge object,
- multiple views share the same presentation-specific state and that state is not better modeled as app-level environment data.
Even then, keep the view model small, explicit, and non-optional when possible.
## Preferred pattern: local state plus environment
```swift
struct FeedView: View {
@Environment(BlueSkyClient.self) private var client
enum ViewState {
case loading
case error(String)
case loaded([Post])
}
@State private var viewState: ViewState = .loading
var body: some View {
List {
switch viewState {
case .loading:
ProgressView("Loading feed...")
case .error(let message):
ErrorStateView(message: message, retryAction: { await loadFeed() })
case .loaded(let posts):
ForEach(posts) { post in
PostRowView(post: post)
}
}
}
.task { await loadFeed() }
}
private func loadFeed() async {
do {
let posts = try await client.getFeed()
viewState = .loaded(posts)
} catch {
viewState = .error(error.localizedDescription)
}
}
}
```
Why this is preferred:
- state stays close to the UI that renders it,
- dependencies come from the environment instead of a wrapper object,
- the view coordinates UI flow while the service owns the real work.
## Preferred pattern: use modifiers as lightweight orchestration
```swift
.task(id: searchText) {
guard !searchText.isEmpty else {
results = []
return
}
await searchFeed(query: searchText)
}
.onChange(of: isInSearch, initial: false) {
guard !isInSearch else { return }
Task { await fetchSuggestedFeed() }
}
```
Use view lifecycle modifiers for simple, local orchestration. Do not convert these into a view model by default unless the behavior clearly outgrows the view.
## SwiftData note
SwiftData is a strong argument for keeping data flow inside the view when possible.
Prefer:
```swift
struct BookListView: View {
@Query private var books: [Book]
@Environment(\.modelContext) private var modelContext
var body: some View {
List {
ForEach(books) { book in
BookRowView(book: book)
.swipeActions {
Button("Delete", role: .destructive) {
modelContext.delete(book)
}
}
}
}
}
}
```
Avoid adding a view model that manually fetches and mirrors the same state unless the feature has an explicit reason to do so.
## Testing guidance
Prefer to test:
- services and business rules,
- models and state transformations,
- async workflows at the service layer,
- UI behavior with previews or higher-level UI tests.
Do not introduce a view model primarily to make a simple SwiftUI view "testable." That usually adds ceremony without improving the architecture.
## Refactor checklist
When refactoring toward MV:
- Remove view models that only wrap environment dependencies or local view state.
- Replace optional or delayed-initialized view models when plain view state is enough.
- Pull business logic out of the view body and into services/models.
- Keep the view as a thin coordinator of UI state, navigation, and user actions.
- Split large bodies into smaller view types before adding new layers of indirection.
## Bottom line
Treat view models as the exception, not the default.
In modern SwiftUI, the default stack is:
- `@State` for local state,
- `@Environment` for shared dependencies,
- `@Query` for SwiftData-backed collections,
- lifecycle modifiers for lightweight orchestration,
- services and models for business logic.
Reach for a view model only when the feature clearly needs one.