---
title: 'Declarative and Imperative Interfaces Together'
source: 'https://academia.sh/en/courses/uikit/declarative-and-imperative-together'
course: 'UIKit and Bridging'
language: en
updated: '2026-10-21T05:03:15+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Separates the two directions in which the two frameworks meet on one screen, embedding and wrapping'
    - 'Measures that a field stays at its old value when the root view is not reassigned in a hosting controller'
    - 'Verifies that a call written in the update method brings the field back to the model in wrapping'
    - "Names why the declarative side's own recomputation is not seen by this rule"
---

# Declarative and Imperative Interfaces Together

Measuring how a screen falls behind the model when the root view is not reassigned while embedding a declarative view in an imperative screen, and when no update is written while wrapping an imperative view in a declarative screen.

In the previous two topics we read the imperative interface on its own: every value we saw on screen was written by code, and nothing reminded the code that forgot to write. In the previous course (M30/K03) we saw the opposite on the declarative side: when a value changed, the framework recomputed the view that reads it by itself. Real applications are rarely built from only one of the two. An old screen carries a declarative piece, a new screen takes an old piece inside it. In this lesson we look at the seam between the two.

The seam has two directions. A declarative view can enter an imperative screen, which we will call **embedding**. An imperative view can enter a declarative screen, which we will call **wrapping**. In embedding, the declarative framework takes the input and the framework computes it; in wrapping, the imperative view takes the input and code writes it. Two frameworks standing side by side on the same screen can exchange values only through these two paths. The frameworks are called UIKit and SwiftUI, but the lesson will not depend on names: we will say the declarative framework and the imperative interface framework.

The measuring axis has been the same since the start of the topic: **agreement**. The question is whether the field shown on screen agrees with the model's current value. For each field the rule returns one of four results: `matching`, `stale`, `missing`, `leftover`. In bridging the question turns into this: when a value crosses the border between the two frameworks, who carries it?

The core file is the same.

```swift
// Sync.swift — compares every field shown on screen with the model's current value
final class Screen {
    let name: String
    private(set) var shown: [String: String] = [:]

    init(_ name: String) { self.name = name }

    func set(_ key: String, _ value: String) { shown[key] = value }
    func clear(_ key: String) { shown[key] = nil }
}

func verdict(model: String?, shown: String?) -> String {
    switch (model, shown) {
    case (nil, nil): return "out-of-scope"
    case (nil, _?): return "leftover"
    case (_?, nil): return "missing"
    case let (wanted?, displayed?): return wanted == displayed ? "matching" : "stale"
    }
}

func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
    keys.map { key in
        let result = verdict(model: model[key], shown: screen.shown[key])
        print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") screen=\(screen.shown[key] ?? "-") -> \(result)")
        return result
    }
}

func countVerdicts(_ verdicts: [String]) {
    for name in ["matching", "stale", "missing", "leftover"] {
        print("\(name): \(verdicts.filter { $0 == name }.count)")
    }
}
```

The rule does not see the cause, it sees only two values. Telling whether a value stayed missing, stale or leftover on screen does not require knowing why; that is why the same rule can be applied to the screens of both frameworks. `Screen` holds the values the screen shows; every call that writes to the screen writes it with `set`, and the model writes in the same way.

## A Declarative View Embedded in an Imperative Screen

Think of a task screen. At the top there is a summary strip: a title, an open task count, a filter. The strip is written in the declarative framework, the rest of the screen stands in the imperative framework. A declarative view enters an imperative screen through a **hosting controller**: a class that wraps the view and adds it as a child controller. The root view is the controller's `rootView` property.

```swift
import UIKit
import SwiftUI

final class HostingController: UIViewController {
    private var host: UIHostingController<SummaryView>!
    var summary = SummaryState(title: "Tasks", openCount: 3, filter: "open")

    override func viewDidLoad() {
        super.viewDidLoad()
        host = UIHostingController(rootView: SummaryView(state: summary))
        addChild(host)
        view.addSubview(host.view)
        host.didMove(toParent: self)
    }

    func summaryDidChange() {
        // host.rootView = SummaryView(state: summary)
    }
}
```

The place to watch is `summaryDidChange`. In the declarative framework, when a view's input changes, the framework recomputes the view. But here the input is a **copy** handed to the constructor of `SummaryView`: even if the `summary` property changes later, the state in the root view's hands stays as it was. The framework's recomputation kicks in only when `rootView` is reassigned. Without the assignment line the framework does not see the change; that is the imperative side's job.

The model below builds what the code writes to the screen; it is not the framework itself. The hosting controller is rebuilt in plain Swift and carries the names in the SOURCE snippet: `HostingController`, `rootView`, `SummaryView`, `SummaryState`. The shared helper file builds both screens.

```swift
// Bridging.swift — plain Swift setup of two screens, which assignment writes what
struct SummaryState {
    var title: String
    var openCount: Int
    var filter: String

    var values: [String: String] {
        ["title": title, "openCount": String(openCount), "filter": filter]
    }
}

struct SummaryView {
    let state: SummaryState

    func draw(on screen: Screen) {
        screen.set("title", state.title)
        screen.set("openCount", String(state.openCount))
        screen.set("filter", state.filter)
    }
}

final class HostingController {
    let screen = Screen("HostingController")
    var rootView: SummaryView {
        didSet { rootView.draw(on: screen) }
    }

    init(rootView: SummaryView) {
        self.rootView = rootView
        rootView.draw(on: screen)
    }
}

final class LegacyListView {
    let screen = Screen("LegacyListView")

    init(title: String, rows: [String]) {
        screen.set("title", title)
        reloadData(rows: rows)
    }

    func reloadData(rows: [String]) {
        screen.set("rowCount", String(rows.count))
    }
}
```

The first event: the screen is built with three open tasks, then the model goes up to five and `rootView` is not reassigned.

```swift
// main.swift — model changed, rootView not reassigned
let first = SummaryState(title: "Tasks", openCount: 3, filter: "open")
let latest = SummaryState(title: "Tasks", openCount: 5, filter: "open")

let hosting = HostingController(rootView: SummaryView(state: first))
let withoutAssignment = check("hosting-unassigned", model: latest.values, screen: hosting.screen,
                              keys: ["title", "openCount", "filter"])

countVerdicts(withoutAssignment)
```

```
hosting-unassigned | HostingController.title: model=Tasks screen=Tasks -> matching
hosting-unassigned | HostingController.openCount: model=5 screen=3 -> stale
hosting-unassigned | HostingController.filter: model=open screen=open -> matching
matching: 2
stale: 1
missing: 0
leftover: 0
```

Two of the three fields are matching, because they did not change in the model. `openCount` is 5 in the model and 3 on screen: **stale**. No error shows on screen, the strip is drawn nicely; only the number is old. The compiler does not warn either, because there is no wrongly written line, there is an unwritten one. When such errors are found by eye, it is usually by someone opening the screen a second time; the measurement runs the same check for every field from start to end.

**BR1.** The model assumes that, when the `rootView` assignment is made, the declarative view writes its new state to the screen. How and at what moment the declarative framework recomputes the view after the assignment is not measured; `draw(on:)` writes the result of that computation in a single call. This is an assumption and holds only for values crossing this border.

Now we open the line and apply the same change once more, this time with the assignment. In this event only one thing changes: `rootView` is reassigned with the new state. The starting values, the model and the checked fields are the same as before, so the difference between the two outputs ties to a single line.

```swift
// main.swift — model changed, rootView reassigned
let first = SummaryState(title: "Tasks", openCount: 3, filter: "open")
let latest = SummaryState(title: "Tasks", openCount: 5, filter: "open")

let hosting = HostingController(rootView: SummaryView(state: first))
hosting.rootView = SummaryView(state: latest)
let withAssignment = check("hosting-assigned", model: latest.values, screen: hosting.screen,
                           keys: ["title", "openCount", "filter"])

countVerdicts(withAssignment)
```

```
hosting-assigned | HostingController.title: model=Tasks screen=Tasks -> matching
hosting-assigned | HostingController.openCount: model=5 screen=5 -> matching
hosting-assigned | HostingController.filter: model=open screen=open -> matching
matching: 3
stale: 0
missing: 0
leftover: 0
```

All three fields are matching. The fix is a single line, but remembering to write that line is left to the programmer. In the declarative framework the framework did this work; the hosting controller hands that work back to the imperative side. The solution lies in the same place: the code that sees the change, that is `summaryDidChange`, must reassign the root view. For a state that can change from several places, gathering this call in a single point reduces the number of places where the assignment can be forgotten.

## An Imperative View Wrapped in a Declarative Screen

The seam works in the opposite direction too. An old list screen has been running soundly for years and a new declarative screen wants to take it inside as it is. What brings an imperative view into the declarative tree is **wrapping**: a type with two required methods, one builds the view once, the other updates the imperative view as the declarative state changes.

```swift
import SwiftUI

struct ListRepresentable: UIViewRepresentable {
    let title: String
    let rows: [String]

    func makeUIView(context: Context) -> LegacyListView {
        LegacyListView(title: title, rows: rows)
    }

    func updateUIView(_ list: LegacyListView, context: Context) {
        // list.reloadData(rows: rows)
    }
}
```

`makeUIView` runs once while the screen opens. `updateUIView(_:context:)` is called each time the declarative state changes; carrying the new value to the imperative view is its job. Here its body is left empty: the model sets up the situation where nothing is written when the framework makes the call.

**BR2.** The model assumes that the framework calls the `updateUIView` method when the state changes; in the measurement our code makes the call. The timing and frequency of the call are not measured. What is measured is what the body writes to the imperative screen when the call is made.

```swift
// main.swift — updateUIView body empty, list is not reloaded
struct ListRepresentable {
    let title: String
    let rows: [String]

    func makeUIView() -> LegacyListView {
        LegacyListView(title: title, rows: rows)
    }

    func updateUIView(_ list: LegacyListView) {
        // empty
    }
}

let before = ListRepresentable(title: "Tasks", rows: ["a", "b", "c"])
let after = ListRepresentable(title: "Tasks", rows: ["a", "b", "c", "d", "e"])

let list = before.makeUIView()
after.updateUIView(list)

let model = ["title": "Tasks", "rowCount": String(after.rows.count)]
let emptyUpdate = check("wrapping-empty", model: model, screen: list.screen,
                        keys: ["title", "rowCount"])

countVerdicts(emptyUpdate)
```

```
wrapping-empty | LegacyListView.title: model=Tasks screen=Tasks -> matching
wrapping-empty | LegacyListView.rowCount: model=5 screen=3 -> stale
matching: 1
stale: 1
missing: 0
leftover: 0
```

The title was written at the first setup and did not change: matching. The row count is 5 in the model, while the list stayed with its first three rows: stale. This has the same shape as the first event; we are only crossing the border from the opposite direction. In both cases the call that would carry the value has not been written. In embedding, what was missing was the assignment; in wrapping, what is missing is the `reloadData` call; the rule names both with the same word, `stale`.

Now we write the body. The snippet carries the `rows` value from the declarative state to the imperative view with `reloadData`; everything else is the same as the previous snippet.

```swift
import SwiftUI

struct ListRepresentable: UIViewRepresentable {
    let title: String
    let rows: [String]

    func makeUIView(context: Context) -> LegacyListView {
        LegacyListView(title: title, rows: rows)
    }

    func updateUIView(_ list: LegacyListView, context: Context) {
        list.reloadData(rows: rows)
    }
}
```

We measure the same two situations, this time with the `reloadData` call.

```swift
// main.swift — updateUIView body reloads the list
struct ListRepresentable {
    let title: String
    let rows: [String]

    func makeUIView() -> LegacyListView {
        LegacyListView(title: title, rows: rows)
    }

    func updateUIView(_ list: LegacyListView) {
        list.reloadData(rows: rows)
    }
}

let before = ListRepresentable(title: "Tasks", rows: ["a", "b", "c"])
let after = ListRepresentable(title: "Tasks", rows: ["a", "b", "c", "d", "e"])

let list = before.makeUIView()
after.updateUIView(list)

let model = ["title": "Tasks", "rowCount": String(after.rows.count)]
let forwardedUpdate = check("wrapping-filled", model: model, screen: list.screen,
                            keys: ["title", "rowCount"])

countVerdicts(forwardedUpdate)
```

```
wrapping-filled | LegacyListView.title: model=Tasks screen=Tasks -> matching
wrapping-filled | LegacyListView.rowCount: model=5 screen=5 -> matching
matching: 2
stale: 0
missing: 0
leftover: 0
```

Both fields are matching. This is what wrapping is for: the path by which the declarative state reaches the imperative view after the view is built is `updateUIView`, and it carries as much as the lines written. The old list screen's own lifecycle is unaware of this call; the declarative framework announces the change, but the code that turns the announcement into something the imperative view understands is you.

## The Bounding Measurement

In this section we separate the scope of the measurement. So far both screens showed only the fields that code wrote. A state the declarative view keeps **inside itself** is different: a collapsible section of the strip, a scroll position, a focus. The framework recomputes these values by itself and the screen code does not write them to `Screen`.

```swift
// main.swift — the declarative side's own state is never written to the screen
let state = SummaryState(title: "Tasks", openCount: 5, filter: "open")
let hosting = HostingController(rootView: SummaryView(state: state))

let model = ["openCount": "5"]
let ownState = check("own-state", model: model, screen: hosting.screen,
                     keys: ["openCount", "isExpanded"])

countVerdicts(ownState)
```

```
own-state | HostingController.openCount: model=5 screen=5 -> matching
own-state | HostingController.isExpanded: model=- screen=- -> out-of-scope
matching: 1
stale: 0
missing: 0
leftover: 0
```

`isExpanded` is absent on both sides: the result is `out-of-scope` and it does not enter the count. Whether this field is right on screen the rule does not say, because the model never set it up. A full matching count does not mean everything on the screen is up to date; it says only that the fields that were written are up to date. The declarative side's own recomputation stays outside this measurement. That is why the two assumptions above were written out openly: the model counts the work the declarative side does as written once at the assignment and call points. Telling when the real framework does this work is the job of the lesson text and the framework documentation, not of the measurement. This lesson claims only one thing: if the code itself did not write the call that carries the value across the border, the screen falls behind the model.

## Summary

- A declarative view enters an imperative screen through a hosting controller; if `rootView` is not reassigned, the screen stays at three while the model goes up to five and the open task count is measured **stale** (2 matching, 1 stale).
- When the assignment is written, all three of the same fields are matching (3 matching).
- An imperative view enters a declarative screen by wrapping; if the `updateUIView` body is empty, the row count stays 3 on screen while it is 5 in the model (1 matching, 1 stale). When `reloadData` is written into the body, both fields are matching (2 matching).
- In both directions the imperative side writes the call that carries the value across the border; without the call the rule sees only the divergence and does not say its cause.
- When the declarative side's own state is not set up in the model, the result is `out-of-scope` and it does not enter the count (1 matching, 0 others).
- This lesson's agreement: **stale**.

## Next Step

In the next lesson, embedding and wrapping do not stay on a single screen but spread across a whole application: we will cover the migration of legacy code to the declarative framework, screen by screen. There the direction of the question will change: we will look not at the value crossing the border, but at what happens when it does not find its place on the new screen.
