Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.
Question set
8 detailed answers
01@State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject: who owns the source of truth?
middle
Short answer: With @State and @StateObject the view OWNS the source of truth: SwiftUI stores the value outside the struct, and it survives body recomputation. @Binding and @ObservedObject borrow someone else's state; @EnvironmentObject borrows it through the tree's environment.
In depth:
| Wrapper | Who owns it | Survives parent re-render |
|---|---|---|
| @State | the view (value type) | ✓ |
| @StateObject | the view (object) | ✓ |
| @Binding | the parent | — (reference to borrowed state) |
| @ObservedObject | outside code | ✗ if created inline |
| @EnvironmentObject | an ancestor via .environmentObject |
— |
The #1 interview trap:
struct ParentView: View {
@State private var counter = 0
var body: some View {
Button("\(counter)") { counter += 1 }
// ✗ a new VM on every recomputation of the parent's body
ChildView(vm: ChildViewModel())
}
}
struct ChildView: View {
@ObservedObject var vm: ChildViewModel // state gets lost
// @StateObject var vm = ChildViewModel() — would be created once
}
Since iOS 17 the direction is the @Observable macro: ownership via @State, borrowing via a plain property or @Bindable; only the fields actually read are tracked.
⚠️ Common mistake: creating an @ObservedObject right in the view's initializer — every parent re-render recreates the object and wipes its state. The owner creates it with @StateObject; consumers receive it as @ObservedObject.
02Why does the wrong id in ForEach break animations and row state?
senior
Short answer: SwiftUI matches views across updates by identity. A stable id tells the framework "this is the same row, it moved" — the move animates and @State is preserved. An index-based id sticks to the position, not the data, on insert/remove: state and animations end up attached to the wrong rows.
In depth:
- Structural identity — position in the tree: the branches of an
if/elseare two different views; switching resets @State and plays a transition. - Explicit identity —
ForEach(_, id:)and.id(): the id must be stable for the lifetime of the data, not of a single render. - How an index breaks it — after deleting the first row, "row 0" still exists but shows different data: its @State (say, isExpanded) has migrated to a neighbor, and instead of a move animation you get content jumping in place.
// ✗ id = index: state sticks to the position, not the data
ForEach(items.indices, id: \.self) { i in
Row(item: items[i])
}
// ✓ stable id: SwiftUI sees the move and animates it
ForEach(items) { item in // Item: Identifiable
Row(item: item)
}
⚠️ Common mistake: minting identity on the fly — UUID() in a computed id or an unstable hash. Every update then looks like "delete everything + insert everything": state resets, move animations disappear, diffing degrades.
03When does SwiftUI recompute body, and what does "body must be cheap" mean?
middle
Short answer: body is recomputed when any observed dependency of the view changes: @State, @Binding, a @Published property of an observed object, an environment value. A "cheap" body is a pure description of the UI: no side effects, no heavy computation, no creation of objects with identity.
In depth:
- Triggers — a change to the view's own @State, an observed object's publisher, an environment value; with @Observable — only the fields actually read inside body.
- Purity — the framework is free to call body any number of times, whenever it wants; any side effect in it (analytics, writes, networking) fires an unpredictable number of times.
- Localization — split views into subviews: a dependency only touches the bodies that read it, and diffing updates a smaller piece of the tree.
// ✗ heavy work on every recomputation
var body: some View {
let formatter = DateFormatter() // allocation every time
let sorted = items.sorted { $0.date > $1.date }
return List(sorted) { ItemRow(item: $0) }
}
// ✓ formatter — static let, sorting — in the model,
// body — description only
⚠️ Common mistake: "SwiftUI redraws the whole screen on any change." No: it recomputes the bodies of dependent views — which is cheap if body is pure; only the actual diff turns into pixels.
04Why are SwiftUI Views structs, and why isn't constantly recreating them expensive?
junior
Short answer: A View is not a view in the UIKit sense but a lightweight value-description: a blueprint of what to show. Structs are cheap to create and throw away, so SwiftUI freely recreates them on every body recomputation. The long-lived part — the render tree (attribute graph) — lives behind the scenes; that's where state and identity are kept.
In depth:
- Blueprint, not instance — a UIView is a heavyweight object with a lifecycle and a layer; a View struct is a couple of fields plus body, holding no resources.
- State lives outside — @State is stored by the framework and attached to the view's identity in the tree, not to the struct: the struct dies, the value survives.
- Diffing — SwiftUI compares the new description with the old one and minimally mutates the persistent tree; only the difference reaches the pixels.
body → View values (cheap, recreated every time)
│ diff
▼
attribute graph / render tree (long-lived:
@State, identity, animations)
⚠️ Common mistake: equating "recreating structs" with "redrawing the UI." Producing descriptions costs pennies; rendering is what can be expensive, and it happens only where the diff found a change.
05How do you embed a UIKit view in SwiftUI: what do makeUIView, updateUIView and the Coordinator do?
middle
Short answer: UIViewRepresentable (and UIViewControllerRepresentable) is the bridge into UIKit: makeUIView creates the view once, updateUIView syncs it with SwiftUI state on every update, and the Coordinator is a long-lived object for delegates and target-action. The reverse direction is UIHostingController.
In depth:
struct SearchField: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextField {
let field = UITextField() // created ONCE
field.delegate = context.coordinator
return field
}
func updateUIView(_ field: UITextField, context: Context) {
if field.text != text {
field.text = text // sync only
}
}
func makeCoordinator() -> Coordinator { Coordinator(text: $text) }
final class Coordinator: NSObject, UITextFieldDelegate {
var text: Binding<String>
init(text: Binding<String>) { self.text = text }
func textFieldDidChangeSelection(_ field: UITextField) {
text.wrappedValue = field.text ?? ""
}
}
}
- make/update split — creation vs synchronization; the struct gets recreated, the UITextField and Coordinator live on.
- Coordinator — receives UIKit callbacks and writes back into SwiftUI through the Binding.
⚠️ Common mistake: configuring or recreating the view inside updateUIView — you'll reset UIKit state (cursor, scroll position) and pay extra work on every body recomputation.
06SwiftUI or UIKit in 2026: how do you choose on a real project?
concept
Short answer: Default to SwiftUI: development velocity, previews, a state-driven model. Reach for UIKit deliberately — where you need full control: complex collection layouts, custom gesture systems, fine-grained render work, old deployment targets. The mature answer is per-screen interop, not religion.
In depth:
| Factor | SwiftUI | UIKit |
|---|---|---|
| New screen / app | ✓ by default | — |
| Velocity, previews, iteration | ✓ | — |
| Complex collection layouts, gestures | hits walls in places | ✓ full control |
| Fine render/animation control | limited by API | ✓ CALayer, display link |
| Old deployment target | the older the iOS, the more it hurts | ✓ any |
| Large existing UIKit codebase | embeds via UIHostingController | ✓ already there |
- Screen by screen — UIHostingController and UIViewRepresentable let you mix the two worlds without rewriting the app.
- Team and codebase — the team's skills and existing code weigh more than framework ideology.
- Trend — Apple invests in SwiftUI (new APIs are often SwiftUI-first), so the long-term bet is obvious.
⚠️ Common mistake: a categorical "SwiftUI only" or "SwiftUI isn't ready." The interviewer is listening for factor-based judgment, not a slogan.
07What do @Environment and @AppStorage give you, and how is .task different from onAppear?
middle
Short answer: @Environment is dependency injection down the tree (system values and your own objects) without threading parameters through. @AppStorage is a @State-like wrapper over UserDefaults. .task ties async work to the view's LIFETIME: it's cancelled on disappear and restarts when its id changes; onAppear is a synchronous callback where you manage Tasks by hand.
In depth:
- @Environment — reads a value from the environment:
\.dismiss,\.colorScheme, custom keys and @Observable objects; change it at an ancestor and every reader updates. - @AppStorage — persistent state: writes go to UserDefaults, and an external change recomputes body.
- .task vs onAppear —
.taskinherits actor context, auto-cancels and supportsid:;onAppearis for synchronous things like analytics.
struct ProfileView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage("theme") private var theme = "system"
let userID: User.ID
@State private var user: User?
var body: some View {
content
// restarts when userID changes,
// cancelled when leaving the screen
.task(id: userID) {
user = try? await api.loadUser(userID)
}
}
}
⚠️ Common mistake: onAppear { Task { … } } — that Task won't be cancelled when the screen goes away and will keep loading data for a dead view; .task gives you that for free.
08Design the data flow for a screen: a list plus detail editing with save.
senior
Short answer: One source of truth — an observable model at the list level (@StateObject or @Observable inside @State). The detail screen gets either a Binding (edits visible instantly) or a draft copy with an explicit Save — transactional editing. Loading lives in the list's .task; saving is an explicit model method.
In depth:
- Ownership — the list screen owns the store; the detail only borrows data and reports the result.
- Binding straight through — minimal code, but every keystroke is immediately visible in the list and the store: "Cancel" requires a manual rollback.
- Draft copy — a struct copy of the item in the detail's @State; Save hands it to the store, Cancel is free — the draft simply dies.
@Observable final class ItemsStore {
var items: [Item] = []
func load() async { /* fetch */ }
func save(_ draft: Item) {
// replace the item by id, push to the backend
}
}
struct DetailView: View {
@State private var draft: Item // transactional copy
let onSave: (Item) -> Void
init(item: Item, onSave: @escaping (Item) -> Void) {
_draft = State(initialValue: item)
self.onSave = onSave
}
var body: some View {
Form { TextField("Title", text: $draft.title) }
.toolbar { Button("Save") { onSave(draft) } }
}
}
⚠️ Common mistake: passing a Binding straight into the store and being surprised that unsaved edits show up in the list while "Cancel" cancels nothing. Transactional editing is a deliberate decision, not something you get by accident.
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.