Home

Composition over configuration

Small types, wrapped, instead of one type with fifty properties

UIKit’s UILabel has around forty configurable properties. SwiftUI’s Text has none — every question you would answer with a property is answered by wrapping Text in something else.

Text("Hello")
    .font(.headline)
    .foregroundStyle(.secondary)
    .padding(.horizontal, 12)

None of those four lines mutates the Text. Each returns a new value wrapping the previous one, and what reaches the renderer is a nest four layers deep. That is the whole trade: a large API surface on one type is replaced by a small API surface on many types that fit together.

Why the trade is worth taking

The obvious win is that behaviour composes without the framework anticipating your combination. .padding() was not written with Text in mind; it works on anything, including views that did not exist when it was written, including yours.

The subtler win is that there is no invalid state to configure your way into. A UILabel with numberOfLines = 0 and a fixed height and adjustsFontSizeToFitWidth set is a real object in a contradictory state, and the class has to decide what to do about it. A SwiftUI view has no contradictory state because it has no state — it is a value, and the contradiction can only exist as two wrappers disagreeing, which is resolved by the layout rules rather than by a property precedence table nobody remembers.

Tip

The test for whether you have understood this: .font(.title) applied to a VStack works, and it is not a special case. It writes into the environment, and every Text below reads it. A modifier is not “a setter for the view on its left”.

@ViewBuilder, and what it actually builds

The closures you write in body are not ordinary closures. @ViewBuilder is a result builder that turns a sequence of statements into a single nested value:

VStack {
    Text("Title")
    Text("Subtitle")
}

That does not produce an array of views. It produces VStack<TupleView<(Text, Text)>> — a type encoding exactly two children of exactly those types, settled at compile time.

This is why the types get long, and why the compiler used to complain about ten children: every child is a generic parameter. It is also why views are cheap. There is no array to allocate, no heap traffic, no dynamic dispatch to reach the children.

An if in a builder becomes _ConditionalContent<TrueBranch, FalseBranch>, which matters more than it looks — it is why the two branches are different views with different identities, a point the next chapter is entirely about.

Extracting a subview costs nothing

Because a view is a value and its body is only read when needed, splitting a large body into smaller views is free. This is the opposite of the UIKit instinct, where another view means another object, another layer and another thing to lay out.

struct OrderRow: View {
    let order: Order

    var body: some View {
        HStack {
            OrderBadge(status: order.status)
            VStack(alignment: .leading) {
                Text(order.title)
                Text(order.placedAt, format: .dateTime)
            }
        }
    }
}

There is a real performance argument for doing this, not just a tidiness one: SwiftUI invalidates at the granularity of a view. If everything is one enormous body, any change re-runs all of it. Split into OrderBadge and the rest, a status change re-runs OrderBadge.body alone.

Warning

Prefer a struct conforming to View over a var someSection: some View property or a @ViewBuilder function. Computed properties and functions are inlined into the parent’s body and get no identity, no storage and no separate invalidation — so you keep the tidiness and throw away the performance benefit.

Where it stops paying

Composition has a cost, and honesty about it saves arguing with the framework later.

Deeply wrapped views make type names enormous, which turns a compiler error thirty modifiers down into something unreadable. The fix is extracting subviews, which is the thing you should be doing anyway.

More seriously, not everything composes. Some modifiers must be outermost to work at all (.searchable wants to find a navigation container above it), some only work on a direct child of a specific parent (.listRowInsets outside a List is silently ignored), and a few are order sensitive in ways no rule predicts. When a modifier does nothing, the first hypothesis should be that it is in the wrong place in the nest, not that it is broken.