Why SwiftUI Feels Backwards to Traditional Developers

Published by malhal on

If you spent years building software in UIKit, AppKit, Android XML/Views, or even the traditional DOM, your brain has been hardwired around a single, foundational assumption: a view is a long-lived object residing at a stable address in memory.

In traditional Object-Oriented UI paradigms, UI components are stateful instances of reference types. You instantiate a UIButton or a UILabel, configure its properties, hand it off to a parent container, and hold onto a reference—often as an @IBOutlet or a strong property on a view controller. When something in your application changes, you reach directly into memory using that reference and mutate the view.

When developers bring this mental model to SwiftUI, everything feels unnerving. They immediately look for the reference. They try to find getViewByID(), pass around view instances in variables, or store child view structs in parent properties to “optimize” performance.

And that is where the friction begins.

SwiftUI isn’t difficult because its syntax is complex; it is difficult because it actively punishes the patterns that made you successful in traditional OOP. To master SwiftUI, you have to understand why the framework feels so utterly backwards—and how to stop fighting its underlying mechanics.

1. The Identity Fallacy: “Where Is My View?”

In UIKit or traditional frameworks, the lifetime of the view instance maps 1:1 to the visual element rendered on screen. If a text field exists on screen for three minutes while a user types, that underlying UITextField object lives in memory for those exact three minutes.

// The Imperative / OOP Mental Model (UIKit)
class UserProfileViewController: UIViewController {
    let nameLabel = UILabel()

    func updateUserName(_ newName: String) {
        // Direct, imperative reference mutation.
        // `nameLabel` is a stable object sitting at a persistent memory address.
        self.nameLabel.text = newName
    }
}

In SwiftUI, views are not objects. They are value types—temporary struct descriptions created on the stack that serve as lightweight layout blueprints.

// The SwiftUI Reality
struct UserProfileView: View {
    let name: String

    var body: some View {
        // This view body is NOT a persistent object layout.
        // It is an ephemeral receipt describing what the UI should look like right now.
        Text(name)
            .font(.headline)
    }
}

When SwiftUI evaluates UserProfileView, it does not instantiate persistent display nodes. It executes your bodyproperty, reads the values, builds a declarative description, and immediately discards the UserProfileView struct.

If the underlying state changes 50 milliseconds later, SwiftUI doesn’t reach into your existing view struct to call an update method. It instantiates a brand-new UserProfileView struct, calls body again, diffs the resulting value trees, and applies the delta to the underlying rendering engine.

Traditional OOP / Imperative:
[ Persistent View Controller ] ──────> Manages & Mutates ──────> [ Long-Lived View Object in Memory ]

SwiftUI Declarative:
[ State / Single Source of Truth ] ──> Generates Ephemeral Blueprint ──> [ SwiftUI Engine Diffs & Renders ]

Pointer Identity vs. Structural Identity

Because traditional developers treat views as objects, they naturally expect Pointer Identity (Reference Identity). If two variables point to the same memory address, they are the same view.

SwiftUI completely discards Pointer Identity in favor of Structural Identity and Explicit Identity:

  1. Structural Identity: SwiftUI tracks where a view sits inside your view hierarchy tree. An if / else branch inside a VStack defines two distinct structural identities, even if both branches return a text view displaying identical strings.
  2. Explicit Identity: Identity explicitly assigned using custom identifiers or data collections, such as .id("uniqueID")or ForEach(items, id: \.id).

Consider what happens when a developer tries to preserve state by creating a “reusable view instance” as a stored property—a classic UIKit optimization:

// ❌ THE ANTI-PATTERN: Treating SwiftUI Views like long-lived objects
struct DashboardView: View {
    // Attempting to "hold onto" a view instance across render cycles:
    let cachedHeaderView = HeaderView() 

    var body: some View {
        VStack {
            cachedHeaderView // Retains old state, misses dynamic updates!
            MainContentView()
        }
    }
}

In UIKit, caching a view instance prevents unnecessary recreation. In SwiftUI, doing this detaches the view from dynamic structural tree re-evaluations, locking it into whatever state existed when DashboardView was first initialized.

2. Reversal of Control: State Doesn’t Drive UI; UI is a Function of State

In an imperative world, execution flows sequentially from events to UI mutations:

User Taps Button⟶Event Handler Runs⟶Mutate Target View Properties

SwiftUI flips this flow upside down:

User Taps Button⟶State Changes⟶Graph Invalidates⟶SwiftUI Re-evaluates Body

This causes massive friction when developers try to force imperative sequences onto declarative structures. You cannot easily tell SwiftUI: “Animate this view, wait 2 seconds, clear the text field, and then trigger navigation.”

Because view structs are re-evaluated unpredictably and frequently during render passes or animation frames, putting imperative side-effects inside view bodies or initializers wreaks havoc.

// ❌ DANGEROUS: Side-effects inside ephemeral view evaluation
struct FeedView: View {
    init() {
        // This init might run 20 times during an animation parent transition!
        NetworkManager.shared.fetchFeedData() 
    }
    
    var body: some View {
        Text("Feed")
    }
}

If you treat a body execution like a method you control, you will trigger duplicate network calls, state race conditions, and frame drops.

3. The Illusion of Simplicity (The WWDC Demo Trap)

Apple advertises SwiftUI as effortlessly simple: attach a @State wrapper, add a $binding, and you have an editing form in 10 lines of code.

// Standard Demo Code
struct QuickEditView: View {
    @State private var text: String = "Hello"

    var body: some View {
        TextField("Title", text: $text)
    }
}

This works beautifully on a WWDC slide. But the moment you scale to production—handling background database syncs, transactional edits, multi-field validation, or draft cancellations—the abstraction leaks.

Developers often fall into the trap of trying to seed child @State from parent properties, leading to stale drafts and out-of-sync views:

// 🚩 Red Flag: Seeding state in init
struct EditItemView: View {
    var item: Item
    @State private var name: String

    init(item: Item) {
        self.item = item
        self._text = State(initialValue: item.name) // Breaks when item updates externally!
    }
    
    var body: some View {
        TextField("Name", text: $name)
    }
}

When item.name changes externally (via a background sync or parent update), EditItemView will ignore the new value because @State only initializes once when the structural identity node is first attached to the graph.

Developers then layer on .onAppear.onChange, and @FocusState in a desperate attempt to force child view state to mirror parent model state—building complex, buggy synchronization loops that fight the framework at every turn.

4. Inverted Ownership: Children Are Passive Renderers

In Object-Oriented architecture, encapsulation dictates that child components should own their internal state and notify parents when work is complete via delegates or completion closures.

In SwiftUI, trying to make child views own their draft lifecycles is an architectural dead end. Child views should be pure, passive renderers.

Instead of forcing a child View to manage local draft buffers, true production SwiftUI architecture pushes state control up into dedicated presentation items or child contexts instantiated at the moment of user interaction:

// Presentation-Scoped Draft State Model
class FormDraft: Identifiable, ObservableObject {
    let context: NSManagedObjectContext
    let item: Item
    @Published var draftName: String = ""

    init(viewContext: NSManagedObjectContext, item: Item) {
        // Create an isolated child context for editing
        self.context = NSManagedObjectContext(childOf: viewContext)
        self.item = item
        self.draftName = item.name
    }
    
    func save() throws {
        try context.save()
    }
}

By coupling the draft lifecycle to a presentation item passed via optional state (.sheet(item: $draft)), the entire form lifecycle becomes clean, deterministic, and isolated from parent re-render noise:

struct ParentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @State private var activeDraft: FormDraft?

    var body: some View {
        Button("Edit") {
            // Instantiate draft state synchronously at the moment of user interaction
            activeDraft = FormDraft(viewContext: viewContext, item: selectedItem)
        }
        .sheet(item: $activeDraft) { draft in
            // Child view is pure and side-effect free
            EditFormView(draft: draft)
        }
    }
}

5. Layout Engine Inversion: Outside-In vs. Inside-Out

The “backwards” feeling extends all the way down to SwiftUI’s layout system.

In UIKit or web development, layout is typically Top-Down (parents dictate absolute frames to children) or Bottom-Up (child contents expand parent boxes in Flexbox/AutoLayout).

SwiftUI uses a strict 3-Step Layout Handshake:

  1. Parent offers a size to the child (e.g., “You have 300x500pt available”).
  2. Child determines its own size based on its internal rules and returns that size to the parent (e.g., “I only need 100x40pt”).
  3. Parent positions the child within its bounds.
[ Parent offers 300x500 ] ──> [ Child decides it wants 100x40 ] ──> [ Parent places Child ]

Because child views choose their own dimensions, modifiers don’t mutate an existing view—they wrap it in a new structural container. This is why modifier order in SwiftUI changes layout entirely:

// Example 1: Padding applied FIRST, then background fills padded area
Text("Hello")
    .padding()
    .background(Color.blue)

// Example 2: Background fills tight text bounds FIRST, padding added outside
Text("Hello")
    .background(Color.blue)
    .padding()

Summary: How to Stop Fighting the Framework

If SwiftUI feels backwards to you, it’s not because you lack programming experience—it’s because your hard-earned imperative instincts are pushing you to grab hold of objects that no longer exist.

To write clean SwiftUI at scale:

  • Stop looking for view references. Treat View structs like ephemeral value math functions, not persistent objects in memory.
  • Stop seeding @State in child view initializers. Use presentation-scoped draft objects tied to presentation lifecycles (.sheet(item:)).
  • Keep view bodies side-effect free. Never trigger network calls, database writes, or imperative state mutations inside init or un-guarded body evaluations.
  • Embrace structural identity. When a view isn’t updating correctly, the solution is almost never “force a redraw”—it’s putting state ownership in the right place.

Once you stop treating SwiftUI like a lightweight version of UIKit and accept its value-centric execution graph, the framework stops feeling broken—and starts feeling effortless.

Categories: SwiftUI