DEV Community

Emad Beyrami
Emad Beyrami

Posted on

Breaking Up Big SwiftUI Views the Right Way

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 {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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 View types 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 View type 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?
}
Enter fullscreen mode Exit fullscreen mode

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now ExpensiveList has its own type, identity, dependencies, and state ownership.

The important distinction is:

@ViewBuilder helps you express view structure. A separate View type 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
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

You might then write:

Rectangle()
    .applyIf(isCompact) {
        $0.frame(width: 100)
    }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 if in SwiftUI."

That's completely wrong.

This is perfectly normal:

if isLoading {
    ProgressView()
} else {
    ContentView()
}
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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 {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

isn't automatically faster than:

private var smallView: some View {
    Text("Hello")
}
Enter fullscreen mode Exit fullscreen mode

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 body being evaluated?
  • Which state changes cause the evaluation?
  • Is expensive computation happening during body evaluation?
  • 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.

@ViewBuilder is for expressing view structure and conditional composition.

Separate View types 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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And keep small structural branches local:

@ViewBuilder
private var connectionStatus: some View {
    switch status {
    case .offline:
        OfflineBadge()

    case .syncing:
        ProgressView()

    case .connected:
        ConnectedBadge()
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Further reading

Top comments (0)