162 lines
5.4 KiB
Markdown
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.
|