5.4 KiB
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:), andonChangebefore 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, orBinding-based data flow, - exist only because the view body is too long,
- hold one-off async loading logic that can live in
.taskplus 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
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
.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:
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:
@Statefor local state,@Environmentfor shared dependencies,@Queryfor 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.