Refactor SwiftUI views toward small, explicit, stable view types. Default to vanilla SwiftUI: local state in the view, shared dependencies in the environment, business logic in services/models, and view models only when the request or existing code clearly requires one.
body implementations.private/public let
@State / other stored propertiesvar (non-view)init
body
@State, @Environment, @Query, .task, .task(id:), and onChange before reaching for a view model.@Environment; keep domain logic in services/models, not in the view body.some View helpersbody properties that are longer than roughly one screen or contain multiple logical sections.View types for non-trivial sections, especially when they have state, async work, branching, or deserve their own preview.some View helpers rare and small. Do not build an entire screen out of private var header: some View-style fragments.Prefer:
var body: some View {
List {
HeaderSection(title: title, subtitle: subtitle)
FilterSection(
filterOptions: filterOptions,
selectedFilter: $selectedFilter
)
ResultsSection(items: filteredItems)
FooterSection()
}
}
private struct HeaderSection: View {
let title: String
let subtitle: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.title2)
Text(subtitle).font(.subheadline)
}
}
}
private struct FilterSection: View {
let filterOptions: [FilterOption]
@Binding var selectedFilter: FilterOption
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(filterOptions, id: \.self) { option in
FilterChip(option: option, isSelected: option == selectedFilter)
.onTapGesture { selectedFilter = option }
}
}
}
}
}
Avoid:
var body: some View {
List {
header
filters
results
footer
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.title2)
Text(subtitle).font(.subheadline)
}
}
body.task, .onAppear, .onChange, or .refreshable.Button("Save", action: save)
.disabled(isSaving)
.task(id: searchText) {
await reload(for: searchText)
}
private func save() {
Task { await saveAsync() }
}
private func reload(for searchText: String) async {
guard !searchText.isEmpty else {
results = []
return
}
await searchService.search(searchText)
}
body or computed views that return completely different root branches via if/else.overlay, opacity, disabled, toolbar, etc.).Prefer:
var body: some View {
List {
documentsListContent
}
.toolbar {
if canEdit {
editToolbar
}
}
}
Avoid:
var documentsListView: some View {
if canEdit {
editableDocumentsList
} else {
readOnlyDocumentsList
}
}
init, then create the view model in the view's init.bootstrapIfNeeded patterns and other delayed setup workarounds.Example (Observation-based):
@State private var viewModel: SomeViewModel
init(dependency: Dependency) {
_viewModel = State(initialValue: SomeViewModel(dependency: dependency))
}
@Observable reference types on iOS 17+, store them as @State in the owning view.@StateObject at the owner and @ObservedObject when injecting legacy observable models.body; move business logic into services/models and keep only thin orchestration in the view.some View helpers.if-based branch swapping; move conditions to localized sections/modifiers.@State view model initialized in init.@State for root @Observable models on iOS 17+, legacy wrappers only when the deployment target requires them.some View properties.body and non-view computed vars above init.references/mv-patterns.md.When a SwiftUI view file exceeds ~300 lines, split it aggressively. Extract meaningful sections into dedicated View types instead of hiding complexity in many computed properties. Use private extensions with // MARK: - comments for actions and helpers, but do not treat extensions as a substitute for breaking a giant screen into smaller view types. If an extracted subview is reused or independently meaningful, move it into its own file.