SwiftUI @State, @Binding ve @Observable: Veri Akışı Rehberi
6 dk okuma

SwiftUI'da ekran, verinin bir fonksiyonudur: veri değişir, body yeniden hesaplanır, ekran güncellenir. Bu yüzden asıl soru "ekranı nasıl güncellerim" değil, "bu verinin sahibi kim" sorusudur. @State, @Binding, @Observable, @Bindable ve @Environment bu soruya verilen beş farklı cevaptır. Yanlış olanı seçtiğinizde derleyici çoğu zaman susar, ama ekran kendi kendine sıfırlanan alanlarla ya da güncellenmeyen yazılarla cevap verir.
Bu yazıda önce güncel yaklaşımı (Observation framework), sonra hâlâ pek çok eğitimde ve projede karşınıza çıkan ObservableObject ailesini, ikisi arasındaki birebir eşleşmeyi ve en sık yapılan hataları anlatıyorum. View yapısına yeni başlıyorsanız önce SwiftUI ile ilk uygulama yazısına göz atmak işinizi kolaylaştırır.
@State: View'ın Kendi Verisi
@State, yalnızca o view'ı ilgilendiren küçük değerler içindir: bir sayaç, bir anahtarın açık olup olmadığı, bir metin alanının taslağı. View bir struct olduğu ve her güncellemede yeniden oluşturulduğu için değeri struct'ın içinde saklayamazsınız; @State değeri SwiftUI'ın kendi deposuna taşır ve view yeniden oluşturulsa bile korur.
import SwiftUI
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack(spacing: 12) {
Text("Sayaç: \(count)")
Button("Artır") { count += 1 }
}
}
}private yazmak bir alışkanlık olmalı. @State bir verinin sahibi olduğunuzu söyler; dışarıdan atanabilen bir @State neredeyse her zaman yanlış araç seçildiğini gösterir.
@Binding: Sahibi Başkası Olan Veriyi Değiştirmek
Alt view bir değeri hem okuyacak hem değiştirecek, ama değerin sahibi üst view ise @Binding kullanılır. Binding bir kopya değil, sahibindeki değere açılan iki yönlü bir bağlantıdır. Üst view'da $ öneki ile üretilir.
struct NotificationToggle: View {
@Binding var isOn: Bool
var body: some View {
Toggle("Bildirimler", isOn: $isOn)
}
}
struct SettingsView: View {
@State private var notificationsEnabled = true
var body: some View {
Form {
NotificationToggle(isOn: $notificationsEnabled)
Text(notificationsEnabled ? "Açık" : "Kapalı")
}
}
}Alt view değeri yalnızca gösterecekse binding'e gerek yok; düz bir let parametresi yeterli. Binding'i "yazma izni" olarak düşünün ve yalnızca gerekene verin.
@Observable: Birden Fazla Ekranın Paylaştığı Model
Veri tek bir değerden büyüdüğünde, örneğin bir sepet, bir oturum ya da bir liste olduğunda, onu bir sınıfa taşırsınız. Observation framework'ü ile bunun için sınıfın başına @Observable yazmak yeterli. iOS 17 ve sonrasında kullanılabilir.
import Observation
struct CartItem: Identifiable {
let id = UUID()
var name: String
var price: Double
var quantity = 1
}
@Observable
final class CartModel {
var items: [CartItem] = []
var note = ""
var total: Double {
items.reduce(0) { $0 + $1.price * Double($1.quantity) }
}
func add(_ item: CartItem) {
items.append(item)
}
}Burada @Published yok. Makro, saklanan her özelliği izlenebilir hâle getiriyor ve SwiftUI bir view'ın body içinde gerçekten okuduğu özellikleri kaydediyor. note değiştiğinde yalnızca note okuyan view'lar yeniden çizilir. Modelin neden struct değil class olduğunu merak ediyorsanız struct ve class farkı yazısında ayrıntısı var: burada aynı nesneyi birden fazla ekranın paylaşması gerekiyor, yani referans semantiği istiyoruz.
Modeli oluşturan view onu @State ile tutar. Modeli dışarıdan alan view'da ise hiçbir sarmalayıcıya gerek yoktur:
struct CartScreen: View {
@State private var cart = CartModel()
var body: some View {
List {
ForEach(cart.items) { item in
Text(item.name)
}
CartNoteField(cart: cart)
CartTotalRow(cart: cart)
}
}
}
struct CartTotalRow: View {
let cart: CartModel
var body: some View {
Text("Toplam: \(cart.total, format: .number.precision(.fractionLength(2)))")
}
}@Bindable: Modelin Özelliğine Binding Üretmek
TextField ya da Toggle bir Binding ister. @Observable bir modelin özelliğinden $ ile binding üretebilmek için modeli @Bindable ile işaretlersiniz:
struct CartNoteField: View {
@Bindable var cart: CartModel
var body: some View {
TextField("Sipariş notu", text: $cart.note)
}
}@Environment: Modeli Ağaçtan Aşağı Taşımak
Modeli beş katman boyunca parametre olarak geçirmek yerine ortama koyabilirsiniz. Kökte .environment(_:) ile verilir, ihtiyaç duyan view tipiyle okur:
@main
struct ShopApp: App {
@State private var cart = CartModel()
var body: some Scene {
WindowGroup {
CartBadge()
.environment(cart)
}
}
}
struct CartBadge: View {
@Environment(CartModel.self) private var cart
var body: some View {
Text("Sepette \(cart.items.count) ürün")
}
}Ortamdan okunan bir modele binding gerekiyorsa body içinde yerel bir @Bindable tanımlanır:
struct CheckoutNote: View {
@Environment(CartModel.self) private var cart
var body: some View {
@Bindable var cart = cart
TextField("Sipariş notu", text: $cart.note)
}
}Eski Yaklaşım: ObservableObject Ailesi
iOS 17 öncesini destekleyen projelerde ve eski eğitimlerde Combine tabanlı yapı karşınıza çıkar. Mantık aynıdır, isimler farklıdır:
final class LegacyCartModel: ObservableObject {
@Published var items: [CartItem] = []
@Published var note = ""
}
struct LegacyCartScreen: View {
@StateObject private var cart = LegacyCartModel() // sahibi
var body: some View {
LegacyCartSummary(cart: cart)
.environmentObject(cart)
}
}
struct LegacyCartSummary: View {
@ObservedObject var cart: LegacyCartModel // sahibi değil
var body: some View {
Text("Sepette \(cart.items.count) ürün")
}
}
struct LegacyCartBadge: View {
@EnvironmentObject private var cart: LegacyCartModel
var body: some View {
Text("\(cart.items.count)")
}
}İki önemli fark var. Birincisi, ObservableObject tarafında herhangi bir @Published özellik değiştiğinde nesneyi gözleyen bütün view'lar geçersiz sayılır; Observation ise özellik bazında izler. İkincisi, eski tarafta sahiplik ile gözlem ayrı sarmalayıcılardır (@StateObject ve @ObservedObject) ve bunları karıştırmak aşağıda anlatacağım klasik hatayı üretir.
| Durum | Observation (iOS 17+) | Eski yaklaşım |
|---|---|---|
| View'a özel basit değer | @State |
@State |
| Alt view üstün değerini değiştirecek | @Binding |
@Binding |
| View modeli oluşturuyor ve sahibi | @State + @Observable sınıf |
@StateObject |
| View modeli dışarıdan alıyor, okuyor | düz let / var |
@ObservedObject |
Dışarıdan gelen modele $ ile binding |
@Bindable |
@ObservedObject |
| Uygulama genelinde paylaşılan model | .environment(_:) + @Environment(Tip.self) |
.environmentObject(_:) + @EnvironmentObject |
| Modelde izlenen özellik | ek işaret gerekmez | @Published |
Ne Zaman Kullanılır, Ne Zaman Kullanılmaz?
Yeni bir projede ve minimum hedefiniz iOS 17 ya da üstüyse Observation ile başlayın; daha az kod, daha az gereksiz yeniden çizim. Daha eski bir sürümü desteklemek zorundaysanız ObservableObject hâlâ tam olarak çalışır ve ikisi aynı projede yan yana durabilir, yani geçişi ekran ekran yapabilirsiniz.
Her şeyi modele taşımak da bir hatadır. Bir sheet'in açık olup olmadığı, bir alanın odak durumu ya da bir animasyon bayrağı view'a aittir ve @State olarak kalmalıdır. Tersi de geçerli: aynı veriyi iki ekran gösteriyorsa ikisinde ayrı @State tutmak yerine tek bir sahibe taşıyın. @Environment'ı ise gerçekten yaygın kullanılan modeller için ayırın; her şeyi ortama koymak bağımlılıkları görünmez kılar ve ortama konmamış bir modeli okumaya çalışan view çalışma anında çöker.
Sık Yapılan Hatalar
1. @ObservedObject ile nesne oluşturmak
struct BadProfileView: View {
@ObservedObject var model = LegacyCartModel() // yanlış
var body: some View { Text(model.note) }
}Belirti: Üst view her yeniden çizildiğinde model sıfırdan oluşur; girilen veri kaybolur, ağ isteği tekrar atılır. @ObservedObject nesneyi saklamaz, yalnızca gözler. Çözüm: Nesneyi oluşturan view'da @StateObject, Observation kullanıyorsanız @State.
2. Üstten gelen değeri @State'e kopyalamak
struct NameEditorBad: View {
@State private var name: String
init(name: String) {
_name = State(initialValue: name)
}
var body: some View {
TextField("Ad", text: $name)
}
}Belirti: Üst view'daki isim değişir ama alan eski değeri göstermeye devam eder; alanda yapılan değişiklik de üste ulaşmaz. @State başlangıç değerini yalnızca view ilk kez ekrana geldiğinde kullanır. Çözüm: Veri üstünse @Binding var name: String kullanın ve $name geçirin.
3. State'i ömrü kısa olan view'da tutmak
Bir if dalının içindeki view koşul yanlış olduğunda ağaçtan çıkar ve @State'i de onunla birlikte silinir. Belirti: Detay bölümünü kapatıp açtığınızda yazdığınız not boş gelir. Aynı şey .id(...) değeri değişen view'larda da olur. Çözüm: Korunması gereken değeri, ekranda kalıcı olan üst view'a taşıyın ve aşağıya binding olarak verin.
4. @Bindable'ı unutmak
struct CartNoteFieldBad: View {
let cart: CartModel
var body: some View {
TextField("Not", text: $cart.note) // hata
}
}Belirti: Derleyici cannot find '$cart' in scope hatası verir. Çözüm: let cart yerine @Bindable var cart. Bir de küçük not: @State private var model = Model() ifadesinde Model() view struct'ı her oluşturulduğunda çalışır, SwiftUI yalnızca ilkini saklar. Bu yüzden modelin init'inde ağır iş yapmayın; yüklemeyi .task içine alın. Bunun nasıl yapılacağını async/await rehberinde gösteriyorum.
Mini Senaryo: Görev Listesi
Beş aracın hepsini tek ekranda görelim. Görevler uygulama genelinde paylaşılan bir TodoStore içinde; liste ekranı onu ortamdan okuyor, ekleme sheet'i ise kendi taslağını @State ile tutuyor.
struct TodoItem: Identifiable {
let id = UUID()
var title: String
var isDone = false
}
@Observable
final class TodoStore {
var items: [TodoItem] = []
var remainingCount: Int {
items.filter { !$0.isDone }.count
}
func add(title: String) {
let trimmed = title.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
items.append(TodoItem(title: trimmed))
}
}
struct TodoListScreen: View {
@Environment(TodoStore.self) private var store
@State private var isAdding = false
var body: some View {
@Bindable var store = store
NavigationStack {
List($store.items) { $item in
Toggle(item.title, isOn: $item.isDone)
}
.navigationTitle("Kalan: \(store.remainingCount)")
.toolbar {
Button("Ekle") { isAdding = true }
}
.sheet(isPresented: $isAdding) {
AddTodoSheet(isPresented: $isAdding)
}
}
}
}
struct AddTodoSheet: View {
@Environment(TodoStore.self) private var store
@Binding var isPresented: Bool
@State private var draft = ""
var body: some View {
Form {
TextField("Görev", text: $draft)
Button("Kaydet") {
store.add(title: draft)
isPresented = false
}
.disabled(draft.isEmpty)
}
}
}Kim neyin sahibi? isAdding liste ekranının, draft sheet'in, görevler ise TodoStore'un. Sheet, isPresented binding'i sayesinde kendini kapatabiliyor ama o değerin sahibi değil. List($store.items) her satıra o öğenin binding'ini veriyor; Toggle değiştiğinde remainingCount okuyan başlık da kendiliğinden güncelleniyor. add içindeki guard kullanımı size yabancıysa optionals ve guard let yazısına bakabilirsiniz. Uygulamanın kökünde .environment(TodoStore()) yerine, @State ile tutulan tek bir örneği ortama vermeyi unutmayın.
Sık Sorulan Sorular
@State ile @Binding arasındaki fark nedir?
@State verinin sahibidir ve değeri SwiftUI'ın deposunda saklar. @Binding hiçbir şey saklamaz; başka bir view'ın sahip olduğu değere okuma ve yazma erişimi verir.
@Observable varken ObservableObject öğrenmeme gerek var mı?
Var, çünkü mevcut projelerin ve eğitimlerin büyük kısmı hâlâ onu kullanıyor ve iOS 17 öncesini destekleyen uygulamalarda tek seçenek o. Yeni kodda Observation tercih edin, eskisini okuyup anlayabilecek kadar bilin.
@StateObject yerine @State kullanabilir miyim?
Yalnızca model @Observable makrosuyla işaretliyse. ObservableObject protokolüne uyan bir sınıfı @State ile tutarsanız özellik değişiklikleri view'ı güncellemez; o sınıflar için @StateObject gerekir.
View'ım neden güncellenmiyor?
En sık üç sebep: model @Observable ya da @Published ile işaretli değil, view değeri bir kopyadan okuyor (üstten gelen değer @State'e kopyalanmış), ya da değişiklik body içinde hiç okunmayan bir özellikte oluyor.
İlgili Yazılar
SwiftUI ile İlk Uygulama: Adım Adım Başlangıç Rehberi
Xcode'da proje açmaktan simülatörde çalıştırmaya kadar SwiftUI ile ilk uygulama: App, View, VStack, @State, List ve TextField ile yapılacaklar listesi.
Swift Closures Kullanımı: $0, @escaping ve [weak self]
Swift closure sözdizimini tam formdan $0 kısaltmasına adım adım öğrenin: trailing closure, değer yakalama, @escaping, retain cycle ve [weak self].
Swift async/await Rehberi: Task, Actor ve MainActor Kullanımı
Completion handler'dan async/await'e geçiş: Task, async let, TaskGroup, @MainActor, actor, iptal ve URLSession ile JSON API çağrısı, SwiftUI örneğiyle.