Why computed properties help readability, what @ViewBuilder actually does, and when a separate View type matters
Every SwiftUI codebase eventually grows a view nobody wants to open.
Four hundred lines. A body that scrolls forever. Several layers of modifiers. State, navigation, sheets, animations, and business logic all living in the same place.
The usual first attempt at cleaning it up is to split body into computed properties:
var body: some View {
VStack {
header
content
footer
}
}
private var header: some View {
// ...
}
private var content: some View {
// ...
}
private var footer: some View {
// ...
}
That makes the file easier to read, and that's useful.
But it is important to understand what this refactoring does and doesn't change.
A computed property doesn't create a new View type, a new identity, or an independent dependency-tracking node. It is still part of the same enclosing view's body evaluation.
If you're trying to improve update locality or isolate expensive sections of UI, that's where extracting a separate View type becomes useful.
The important distinction is:
Computed properties organize your source code. Separate
Viewtypes can organize your view hierarchy, dependencies, state, and update work.
Let's look at why.
The mental model that makes SwiftUI easier to reason about
SwiftUI is easier to understand if you think in terms of three concepts:
- Identity — how SwiftUI recognizes something as the same or different across updates.
- Lifetime — how SwiftUI associates state with that identity.
- Dependencies — which pieces of data a view reads and therefore depends on.
Apple describes these concepts as fundamental to how SwiftUI decides what needs to change and when.
The important one for this discussion is dependencies.
Consider:
struct UserNameView: View {
let user: User
var body: some View {
Text(user.name)
}
}
With SwiftUI's Observation system, the view establishes a dependency on user.name because its body reads that property.
If user.email changes but body doesn't read email, that change doesn't create the same dependency.
This is why it's better to think:
"What data does this view depend on?"
rather than:
"Which giant view contains this code?"
Apple's documentation explains that Observation tracks the observable properties actually read during a view's body evaluation.
That distinction becomes important when deciding whether a large view should be split.
A computed property is not a separate view
Consider this:
struct DashboardView: View {
@State private var count = 0
var body: some View {
VStack {
Button("Count: \(count)") {
count += 1
}
expensiveSection
}
}
private var expensiveSection: some View {
ForEach(0..<200) { index in
RowView(index: index)
}
}
}
The computed property makes the source code cleaner.
But expensiveSection is still part of DashboardView.
When DashboardView.body is evaluated again, evaluating the VStack also evaluates the expression that produces expensiveSection.
The compiler doesn't turn that property into an independent SwiftUI view node just because it has a some View return type.
So this:
private var header: some View
private var content: some View
private var footer: some View
is primarily a source-code organization technique.
And that's perfectly fine.
You should absolutely use computed properties when they make a view easier to read.
The mistake is assuming that they automatically create performance boundaries.
Extracting a real View type changes the structure
Now compare that with:
struct DashboardView: View {
@State private var count = 0
var body: some View {
VStack {
Button("Count: \(count)") {
count += 1
}
ExpensiveSection()
}
}
}
private struct ExpensiveSection: View {
var body: some View {
ForEach(0..<200) { index in
RowView(index: index)
}
}
}
ExpensiveSection is now an actual View type.
That gives SwiftUI another node in the hierarchy with its own identity and dependencies.
If ExpensiveSection doesn't depend on count, changing count doesn't automatically mean that the section's own body must be updated because of that state change.
This is an important distinction:
A separate
Viewtype doesn't magically make SwiftUI faster. It gives you a place to establish narrower dependencies and isolate state and work.
That's the real benefit.
Narrow inputs matter more than extraction alone
Here's where view extraction becomes particularly useful.
Imagine a dashboard model:
@Observable
final class DashboardModel {
var sessions: [Session] = []
var currentStreak: Int = 0
var isSyncing = false
var errorMessage: String?
var selectedWorkout: Workout?
}
You could pass the entire model everywhere:
struct SummarySection: View {
let model: DashboardModel
var body: some View {
VStack {
Text(model.sessions.count.formatted())
Text("\(model.currentStreak) days")
}
}
}
This is not automatically wrong.
With the Observation framework, SummarySection forms dependencies on the observable properties it actually reads.
So passing an observable reference does not mean that every property change necessarily invalidates the view.
However, passing narrow inputs can still make the dependency surface much clearer:
struct SummarySection: View {
let sessionCount: Int
let streakDays: Int
var body: some View {
VStack {
Text(sessionCount.formatted())
Text("\(streakDays) days")
}
}
}
Now the contract is explicit.
SummarySection doesn't know that a dashboard model exists.
It doesn't know about synchronization.
It doesn't know about errors.
It doesn't know about selected workouts.
It only needs two values.
That's useful for several reasons:
- The dependencies are obvious.
- The view is easier to preview.
- The view is easier to test.
- The view is easier to reuse.
- Changes to unrelated application state don't become part of the view's interface.
- Expensive calculations can be performed at an appropriate level rather than repeatedly inside the leaf view.
So the principle isn't:
"Never pass a model."
It's:
Prefer the narrowest inputs that make sense for the view.
A practical example
Suppose the original screen looks like this:
struct DashboardView: View {
@State private var model = DashboardModel()
@State private var selectedRange: DateRange = .week
@State private var isChartExpanded = false
var body: some View {
VStack(spacing: 16) {
summarySection
chartSection
controlsSection
}
.sheet(item: $model.activeSheet) { sheet in
SheetHost(item: sheet)
}
.task {
await model.load()
}
}
private var summarySection: some View {
HStack {
MetricTile(
title: "Distance",
value: model.totalDistance(in: selectedRange)
.formatted(.number.precision(.fractionLength(1)))
)
MetricTile(
title: "Streak",
value: "\(model.currentStreak) days"
)
}
}
private var chartSection: some View {
// large chart implementation
}
private var controlsSection: some View {
// controls implementation
}
}
This isn't necessarily bad code.
The computed properties make it readable.
But the screen still owns:
- the model
- the selected range
- chart state
- summary calculations
- chart rendering
- controls
You can introduce real boundaries:
struct DashboardView: View {
@State private var model = DashboardModel()
var body: some View {
DashboardContent(model: model)
.sheet(item: $model.activeSheet) { sheet in
SheetHost(item: sheet)
}
.task {
await model.load()
}
}
}
private struct DashboardContent: View {
let model: DashboardModel
@State private var selectedRange: DateRange = .week
var body: some View {
VStack(spacing: 16) {
SummarySection(
totalDistance: model.totalDistance(in: selectedRange),
streakDays: model.currentStreak
)
ChartSection(
samples: model.samples(in: selectedRange)
)
RangeControls(selection: $selectedRange)
}
}
}
private struct SummarySection: View {
let totalDistance: Double
let streakDays: Int
var body: some View {
HStack {
MetricTile(
title: "Distance",
value: totalDistance
.formatted(.number.precision(.fractionLength(1)))
)
MetricTile(
title: "Streak",
value: "\(streakDays) days"
)
}
}
}
Now the code has more meaningful boundaries.
SummarySection doesn't know about the dashboard model.
RangeControls owns the range selection.
ChartSection can own chart-specific state.
And the dashboard screen doesn't have to know how the summary is rendered.
That's a much stronger reason to extract a view than simply making the file shorter.
Don't confuse body evaluation with rendering
There's another important SwiftUI distinction.
You will sometimes hear:
"The parent body ran, so all the views were rebuilt."
That's misleading.
SwiftUI views are lightweight value descriptions. Evaluating body produces new view values, and SwiftUI uses identity and dependencies to determine what needs to change in the resulting hierarchy.
Evaluating a body does not mean that 200 platform views were necessarily recreated or that 200 rows were necessarily redrawn on screen.
For example:
struct CounterScreen: View {
@State private var count = 0
var body: some View {
VStack {
Button("Count: \(count)") {
count += 1
}
ForEach(0..<200) { index in
RowView(index: index)
}
}
}
}
When count changes, CounterScreen.body can be evaluated again.
That doesn't mean SwiftUI throws away the entire UI and constructs 200 new UIKit/AppKit views.
SwiftUI evaluates the new view description and reconciles it with the existing hierarchy.
This is why statements such as:
"The button rebuilds 200 rows"
should be avoided unless you're very specifically describing body evaluation.
A better statement is:
"The parent body is evaluated again, so the expressions that produce the list are evaluated again. SwiftUI then determines which parts of the resulting hierarchy actually need updating."
That distinction is important.
So when should you extract a View?
Not every computed property deserves its own type.
Use a separate View when it gives you a meaningful seam.
Good reasons include:
1. The section owns state
For example:
struct ChartSection: View {
let samples: [Sample]
@State private var isExpanded = false
var body: some View {
// ...
}
}
If the state belongs specifically to the chart, the chart is a natural owner.
2. The section has a distinct dependency surface
If a component only needs:
let title: String
let value: String
let isHighlighted: Bool
that's a useful boundary.
3. The section contains expensive computation
For example:
struct StatisticsSection: View {
let sessions: [Session]
private var statistics: Statistics {
calculateStatistics(from: sessions)
}
var body: some View {
// ...
}
}
Putting this work behind a meaningful component can make ownership clearer.
But don't assume extraction alone solves the performance problem.
If calculateStatistics is expensive, consider whether the calculation should instead be:
- cached,
- moved into the model,
- computed incrementally,
- performed asynchronously,
- or otherwise optimized.
A new View type isn't a substitute for fixing expensive computation.
4. You want a reusable component
This is the easiest case:
MetricTile(
title: "Distance",
value: "12.4 km"
)
If the component appears in multiple places, make it a component.
5. You want an independently previewable component
This:
#Preview {
SummarySection(
totalDistance: 12.4,
streakDays: 8
)
}
is much easier to work with than constructing an entire application's model just to preview a small section.
Where does @ViewBuilder fit?
This is where SwiftUI developers often mix up two different concepts.
@ViewBuilder is primarily about building view structure.
It allows you to express conditional and multi-view content conveniently:
@ViewBuilder
private var statusIndicator: some View {
switch connectionStatus {
case .offline:
OfflineBadge()
case .syncing:
ProgressView()
case .connected:
Image(systemName: "checkmark.circle.fill")
}
}
This is a perfectly good use of @ViewBuilder.
You don't need three separate view types just because there are three small branches.
@ViewBuilder makes the composition expressive.
What @ViewBuilder does not do
@ViewBuilder is not a performance annotation.
Consider:
struct CounterScreen: View {
@State private var count = 0
var body: some View {
VStack {
Button("Count: \(count)") {
count += 1
}
expensiveList()
}
}
@ViewBuilder
private func expensiveList() -> some View {
ForEach(0..<200) { index in
RowView(index: index)
}
}
}
Adding @ViewBuilder doesn't turn expensiveList() into an independent view type.
It changes how the function can build its result.
The function still belongs to CounterScreen.
If you genuinely want a separate component, make it one:
struct CounterScreen: View {
@State private var count = 0
var body: some View {
VStack {
Button("Count: \(count)") {
count += 1
}
ExpensiveList()
}
}
}
private struct ExpensiveList: View {
var body: some View {
ForEach(0..<200) { index in
RowView(index: index)
}
}
}
Now ExpensiveList has its own type, identity, dependencies, and state ownership.
The important distinction is:
@ViewBuilderhelps you express view structure. A separateViewtype gives you a separate component with its own dependency and state boundary.
But don't extract everything
There's a temptation to take this advice too far.
You don't need:
struct TitleView: View
struct SubtitleView: View
struct IconView: View
struct DividerView: View
for every three lines of SwiftUI.
This can make the code harder to follow.
For small, purely structural pieces, this is often perfectly reasonable:
@ViewBuilder
private var statusView: some View {
if isLoading {
ProgressView()
} else {
Image(systemName: "checkmark")
}
}
Use a separate type when the component has a meaningful responsibility.
Don't create types just to increase the number of files.
The identity trap: conditional modifiers
There is another SwiftUI issue that becomes easier to understand once identity is clear.
Consider this pattern:
extension View {
@ViewBuilder
func applyIf<Content: View>(
_ condition: Bool,
transform: (Self) -> Content
) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
You might then write:
Rectangle()
.applyIf(isCompact) {
$0.frame(width: 100)
}
It looks convenient.
But the if creates structurally different view branches.
When you are actually trying to modify the same logical view, it's usually better to express the condition as a modifier parameter:
Rectangle()
.frame(width: isCompact ? 100 : nil)
The second version keeps the operation on the same logical view rather than conditionally producing two different structures.
This matters for identity, state lifetime, transitions, and animations.
But if is not bad
This distinction is important.
Don't take the previous section to mean:
"Never use
ifin SwiftUI."
That's completely wrong.
This is perfectly normal:
if isLoading {
ProgressView()
} else {
ContentView()
}
Those are genuinely different pieces of UI.
Conditional structure is exactly what @ViewBuilder is designed to express.
The problem is using structural branching to conditionally apply a modifier to what is conceptually the same view.
A useful rule is:
Use structural branching when the content is actually different. Prefer conditional modifier parameters when the content is the same and only a property changes.
State belongs where it is used
Another benefit of extracting views is clearer state ownership.
Instead of:
struct DashboardView: View {
@State private var isChartExpanded = false
@State private var selectedRange: DateRange = .week
@State private var showDetails = false
// hundreds of lines...
}
you can move state toward the component that owns it:
struct DashboardView: View {
@State private var selectedRange: DateRange = .week
var body: some View {
VStack {
ChartSection()
RangeControls(selection: $selectedRange)
}
}
}
struct ChartSection: View {
@State private var isExpanded = false
var body: some View {
// ...
}
}
Now the state has a clear owner.
This also makes the lifetime of that state easier to reason about because SwiftUI associates state with the identity of the view that owns it.
That's one reason structural identity matters.
A useful code-review checklist
When reviewing a large SwiftUI view, ask these questions.
1. Is this computed property only improving readability?
If yes, that's fine.
Don't extract it solely because someone says every some View property should become a struct.
2. Does this section have its own state?
If yes, consider making it a separate View.
3. Does this section have a distinct dependency surface?
If it only needs:
let title: String
let count: Int
let isEnabled: Bool
that's often a good component boundary.
4. Am I passing an entire model when the component only needs a few values?
Prefer narrow inputs when practical.
But remember that with the Observation framework, passing an observable reference does not automatically mean the view depends on every property of that object. Dependencies are established by the properties read by body.
5. Am I doing expensive work during body evaluation?
If yes, ask whether that work should be:
- moved,
- cached,
- simplified,
- performed asynchronously,
- or otherwise optimized.
Extracting a view can improve structure, but it doesn't automatically make expensive algorithms cheap.
6. Am I using @ViewBuilder because I need conditional structure?
Good.
Am I using it because I believe it creates a performance boundary?
That's the wrong reason.
7. Am I conditionally applying a modifier with if?
Ask whether the modifier can instead take a conditional parameter:
.padding(isCompact ? 8 : 16)
rather than creating separate structural branches unnecessarily.
8. Does this component have a meaningful responsibility?
If yes, a separate type may improve the architecture.
If not, a computed property may be clearer.
What about performance?
This is the most important qualification.
Don't turn view extraction into a cargo-cult performance rule.
This:
struct SmallView: View {
var body: some View {
Text("Hello")
}
}
isn't automatically faster than:
private var smallView: some View {
Text("Hello")
}
And making a view smaller doesn't automatically make the application faster.
The value of extraction comes from better separation of dependencies, state, identity, and work.
If you have a performance problem, measure it.
Useful things to investigate include:
- How often is a view's
bodybeing evaluated? - Which state changes cause the evaluation?
- Is expensive computation happening during
bodyevaluation? - Are large collections being transformed repeatedly?
- Is layout expensive?
- Is drawing expensive?
- Is an animation causing frequent updates?
- Is state owned at the wrong level?
- Are observable dependencies broader than necessary?
Use Instruments and SwiftUI's debugging/profiling facilities rather than assuming that a refactoring is faster because it looks more modular.
The rule of thumb
Here's the mental model I use:
Computed properties are for organizing a view's implementation.
@ViewBuilderis for expressing view structure and conditional composition.Separate
Viewtypes are for creating meaningful components with their own identity, state ownership, dependencies, and responsibilities.Narrow inputs make those boundaries easier to reason about.
Performance improvements should be measured rather than assumed.
That's a much more reliable way to structure SwiftUI code.
The practical takeaway
When you encounter a 500-line SwiftUI view, don't immediately turn every computed property into another struct.
Instead, look for meaningful boundaries.
Start with sections that:
- own local state,
- have a distinct set of dependencies,
- perform meaningful computation,
- contain complex UI,
- are reused,
- need independent previews,
- or are updated independently from the rest of the screen.
For example:
struct DashboardView: View {
@State private var model = DashboardModel()
var body: some View {
DashboardContent(
summary: SummaryData(
distance: model.totalDistance,
streak: model.currentStreak
),
samples: model.samples
)
}
}
Then let the components own their own concerns:
struct DashboardContent: View {
let summary: SummaryData
let samples: [Sample]
var body: some View {
VStack {
SummarySection(data: summary)
ChartSection(samples: samples)
}
}
}
And keep small structural branches local:
@ViewBuilder
private var connectionStatus: some View {
switch status {
case .offline:
OfflineBadge()
case .syncing:
ProgressView()
case .connected:
ConnectedBadge()
}
}
That's a good balance.
You get readable code without turning the entire application into hundreds of tiny view types.
Final thought
Breaking up a large SwiftUI view isn't about hitting a magic number of lines.
It's about making the structure of the UI reflect the structure of its responsibilities.
A computed property can make a large view easier to read.
A @ViewBuilder can make conditional composition easier to express.
A separate View type can give a piece of UI a clearer identity, state owner, dependency surface, and responsibility.
And when performance is the motivation, the most important question isn't:
"Did I extract this into a struct?"
It's:
"Did I reduce unnecessary dependencies or expensive work, and can I measure the difference?"
That's the approach that scales.
Top comments (0)