Separate what the screen shows from how it looks. A view model holds UI state and logic as an observable object; the view renders it and sends events. Learn MVVM and unidirectional data flow.
Putting networking and business logic directly in a SwiftUI view makes it untestable and tangled, and mixes concerns. MVVM separates them: the View renders and forwards events, the ViewModel holds state and logic, the Model is the data. This split — the same idea as Android’s MVVM — is what keeps an app testable and maintainable as features pile up.
Logic inside the View:
- can't unit-test without rendering the UI
- networking + formatting + state all tangled in body
- views become huge and fragile
MVVM splits responsibilities:
VIEW (SwiftUI) renders state, forwards user events — no business logic
VIEWMODEL holds UI state + logic (an @Observable object)
MODEL the data + where it comes from (repository / services)
The View observes the ViewModel and sends it events. State flows down,
events flow up (unidirectional data flow).A view model is a reference type marked @Observable that owns the screen’s state and the methods that change it. The view holds it and reads its properties; when they change, the view re-renders. Exposing a single, well-shaped state and keeping mutation inside the view model gives you one clear place for a screen’s behavior.
import SwiftUI
@Observable
final class ProfileViewModel {
var isLoading = false
var user: User?
var errorMessage: String?
func load(id: Int) async {
isLoading = true; errorMessage = nil
do { user = try await repository.fetchUser(id) }
catch { errorMessage = error.localizedDescription }
isLoading = false
}
}
// the view model owns state + logic. (Pre-iOS 17: ObservableObject +
// @Published.) The view just observes it and calls its methods.The view owns its view model with @State, renders from its single state, and forwards events as method calls — usually kicking off async work from the task modifier. This closes the unidirectional loop: state flows down into the UI, events flow up into the view model. The view stays free of logic; it displays state and reports intent.
struct ProfileView: View {
@State private var viewModel = ProfileViewModel()
let userId: Int
var body: some View {
Group {
if viewModel.isLoading { ProgressView() }
else if let msg = viewModel.errorMessage { Text("Error: \(msg)") }
else if let user = viewModel.user { Text(user.name) }
}
.task { await viewModel.load(id: userId) } // event up, on appear
}
}
// state DOWN (read viewModel), events UP (call load). The loop is closed.The cleanest view models expose state so that impossible combinations cannot occur — often a single enum (loading / loaded / error) rather than separate booleans that could contradict. Modeling the screen as one value the UI switches over makes the view a pure function of state and eliminates "loading and error at the same time" bugs.
enum ViewState<T> {
case loading
case loaded(T)
case failed(String)
}
@Observable
final class ProfileViewModel {
var state: ViewState<User> = .loading // ONE value, no contradictions
func load(id: Int) async {
state = .loading
do { state = .loaded(try await repository.fetchUser(id)) }
catch { state = .failed(error.localizedDescription) }
}
}
// one enum instead of isLoading + user + error booleans that can disagree.
// the view switches over 'state' -> impossible states become unrepresentable.