Use the right tool for the mutation:
@State for simple local value types.@Binding to pass a piece of state down.@StateObject to own a reference-type model (created here).@ObservedObject to observe a model created elsewhere.@EnvironmentObject for app-level models injected high up.@AppStorage/@SceneStorage for persistence or per-scene UI state.@Observable (Observation framework) gives you automatic change publishing without ObservableObject.Always mutate on the main actor:
@MainActor final class UserVM: ObservableObject {
@Published var name = ""
func load() async {
let fetched = await api.fetchName()
name = fetched
}
}
Update hooks:
.task { } for async work tied to view life (auto-cancels on disappear)..onChange(of:) for reacting to a specific value changing..onReceive(_:) to bridge Combine publishers.struct ProfileView: View {
@StateObject var vm = UserVM()
var body: some View {
Text(vm.name)
.task { await vm.load() }
.onChange(of: vm.name) { _ in /* side effect */ }
}
}
actor LocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var cont: CheckedContinuation<CLLocation, Never>?
func requestOnce() async -> CLLocation {
manager.delegate = self
manager.requestWhenInUseAuthorization()
manager.requestLocation()
return await withCheckedContinuation { cont in self.cont = cont }
}
nonisolated func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
Task { await cont?.resume(returning: locs.last!) }
}
nonisolated func locationManager(_ m: CLLocationManager, didFailWithError _: Error) {
Task { await cont?.resume(returning: .init()) }
}
}
@MainActor final class LocationVM: ObservableObject {
@Published var location: CLLocation?
private let svc = LocationService()
func fetch() async { location = await svc.requestOnce() }
}
struct NetworkView: View {
@State private var text = ""
let pub: AnyPublisher<String, Never>
var body: some View {
Text(text)
.onReceive(pub) { text = $0 }
}
}
UIViewRepresentable and a Coordinator to forward delegates.struct AttributedLabel: UIViewRepresentable {
var text: NSAttributedString
func makeUIView(context: Context) -> UILabel {
let l = UILabel()
l.numberOfLines = 0
return l
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.attributedText = text
}
}
UIViewControllerRepresentable.final class HostVC: UIViewController {
@IBOutlet weak var container: UIView!
override func viewDidLoad() {
super.viewDidLoad()
let child = UIHostingController(rootView: SettingsView())
addChild(child)
child.view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(child.view)
NSLayoutConstraint.activate([
child.view.topAnchor.constraint(equalTo: container.topAnchor),
child.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
child.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
child.view.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
child.didMove(toParent: self)
}
}
UIHostingConfiguration makes it trivial to use SwiftUI as a cell’s content:cell.contentConfiguration = UIHostingConfiguration {
HStack {
Image(systemName: "video")
Text(model.title)
}
}
When wrapping a UIKit control that uses delegates, forward events via Coordinator:
struct TextFieldUIKit: UIViewRepresentable {
@Binding var text: String
class Coordinator: NSObject, UITextFieldDelegate {
var parent: TextFieldUIKit
init(_ parent: TextFieldUIKit) { self.parent = parent }
func textFieldDidChangeSelection(_ tf: UITextField) {
parent.text = tf.text ?? ""
}
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextField {
let tf = UITextField()
tf.delegate = context.coordinator
tf.addTarget(context.coordinator, action: #selector(updateText(_:)), for: .editingChanged)
return tf
}
@objc private func updateText(_ sender: UITextField) { /* handled by delegate */ }
func updateUIView(_ uiView: UITextField, context: Context) {
if uiView.text != text { uiView.text = text }
}
}
struct Item: Identifiable, Equatable { let id: UUID; var title: String }
List(items) { item in Text(item.title) }
@MainActor @Observable view models per row and avoid heavy work in body.ScrollViewReader + .id() for precise scroll/refresh behaviors..task(id:) to refetch when an input changes (and to auto-cancel previous tasks):.task(id: query) {
results = await search(query)
}
.refreshable { } for pull-to-refresh..onAppear sparingly; it’s called often during layout changes.protocol UserAPI { func profile() async throws -> Profile }
struct LiveUserAPI: UserAPI { /* URLSession code */ }
@MainActor @Observable final class ProfileVM {
private let api: UserAPI
var profile: Profile?
init(api: UserAPI) { self.api = api }
func load() async { profile = try? await api.profile() }
}
#Preview {
ProfileView(vm: .init(api: MockUserAPI()))
}
Equatable if possible.struct ExpensiveRow: View, Equatable {
let model: Item
static func == (l: Self, r: Self) -> Bool { l.model == r.model }
var body: some View { /* heavy view */ }
}
resizable().interpolation(.medium) and avoid large synchronous decoding on main.GeometryReader for simple layouts; consider the Layout protocol (iOS 16+) only when needed.withAnimation(.spring) { isExpanded.toggle() }
.transaction { $0.disablesAnimations = true } to opt out for a subtree when necessary.@State private var show = false
Button("Delete") { show = true }
.alert("Confirm", isPresented: $show) {
Button("Delete", role: .destructive) { /* do it */ }
}
@FocusState private var focused: Bool
TextField("Name", text: $name).focused($focused)
UIHostingConfiguration before a full rewrite.@State and @StateObject; everything else is inputs.@MainActor) and is called via .task.body.© 2025 Sami Sharafeddine — Licensed under CC BY 4.0
See the full LICENSE file for details.