Understanding data flow in SwiftUI 5

Ajaya Mati
5 min readJan 22, 2024
SwiftUI

Apple introduced SwiftUI for the first time on 3 June 2019, and the team has constantly been improving the syntax quality and performance optimization ever since.

Each release shows a drastic(but really good) change in syntax structure and fundamentals. With the release of SwiftUI 5, it completely took apart its reactive foundation by replacing Combine with the new Observation framework.

The changes I’m going to mention are supported only on iOS17+. So You probably don’t have to worry about changing your existing code to update with new changes as previous syntaxes are also supported.

Prior to SwiftUI 5 we had @StateObject, @ObservedObject and to able to uplift them we had to conform to ObservableObject by our custom class. But SwiftUI 5 introduced @Observable& @Bindable simplifying the way we handle reactive class instances.

Now to handle the data model in a View we just need to explore

  1. @State
  2. @Binding
  3. @Observable
  4. @Bindable

1. @State

@State tells that the view owns the instance or variable as the single source of truth. All the SwiftUI Views are struct and so are immutable, and @State provides us the privilege to read and write the instance as a side-effect to other events .

struct ContentView: View {
@State private var isPlaying: Bool = false

var body: some View {
Text("Hello World")
Button(isPlaying ? "Pause" : "Play") { // Read the state.
isPlaying.toggle()
}
}
}

In the above example, we created a @State isPlaying: Booland initialize it with false . We make @State as private as the View owns that property and only that View should initialize it.

We can access its wrapped value by directly referring to it as a shortcut provided by default with SwiftUI as done here isPlaying ? “Pause” : “Play” . And we are also able to mutate it by assigning a new value, which would have been impossible without @State. (Try to mutate isPlaying without the @State annotation).

2. @Binding

Binding also creates a two-way connection to read and write the View properties, but it doesn’t own that property by itself but the value of the @Binding comes from the SuperView.

struct ContentView: View {
@State private var isPlaying: Bool = true

var body: some View {
Text("Hello World")
ButtonView(isPlaying: $isPlaying)
}
}

struct ButtonView: View {
@Binding var isPlaying: Bool
var body: some View {
Button(isPlaying ? "Pause" : "Play") { // Read the state.
isPlaying.toggle()
}
}
}

Here we can see ButtonView holds a binding to isPlaying from the source ContentView.

The dollar sign ($) before isPlaying in ButtonView(isPlaying: $isPlaying) is a shortcut to access the projected value of the isPlaying @State from the ContentView. The projected value of a @State gives us an instance of typeBinding<Value>.

In the ButtonView we are able to read and write just like we did with @State variable on the first example.

So we can safely say that the only difference between a @Binding and a @State is that @State is the single source of truth that stores the original value & @Binding connects a property to a source of truth stored elsewhere.

But both provide a two-way connection to read and write.

3. @Observable

@Observable was introduced in SwiftUI 5 as part of the Swift Observation framework that replaces Combine’s ObservableObject protocol and provides us with a simpler way to handle reactiveness for class instance properties using the existing data flow primitives like State and Environment instead of object-based equivalents such as StateObject and EnvironmentObject.

It’s only applicable to classes. Structs don't need it as struct instances can directly be used inside a view as they are immutable and don’t need property observation.

@Observable class ContentViewModel {
var isPlaying: Bool = false
}

struct ContentView: View {
private var viewModel: ContentViewModel = ContentViewModel()

var body: some View {
Text("\(viewModel.isPlaying ? "playing": "not playing")")
}
}

Unlike ObservableObject earlier to SwfitUI 5, @Observabledon’t need to write @Publish in front of the properties to make them reactive as instances of a @Observable by default publish changes, even if they are not marked by @State or @Enviroment inside a SwiftUI View.

The @Observable adds @ObservationTracked by default to its members and makes them reactive. For properties that do not want to be observed, they need to be annotated with @ObservationIgnored in front of them.

We can use @State or @Binding to mark them as the state variable for that view if needed for two-way connection same as we do with struct instances.

struct ContentView: View {
@State private var viewModel: ContentViewModel = ContentViewModel()

var body: some View {
Text("Hello World")
ButtonView(isPlaying: $viewModel.isPlaying)
}
}

Just like struct instances with @State, we can use the dollar sign ($) to access the projected value, which is Binding<Value>, of that @Observable .

4. @Bindable

@Bindable is used with @Observable instances that help us create @Binding.

struct ContentView: View {
@Bindable private var viewModel: ContentViewModel = ContentViewModel()

var body: some View {
Text("Hello World")
ButtonView(isPlaying: $viewModel.isPlaying)
}
}

I’ve seen so many blogs on Binding vs Bindable. But they have very different functionality and one doesn’t replace the other.

With @Observable we get the one-way read access that can publish changes over time. For two-way read & write access we need @Binding.

To create @Binding-s we can use @State or @Bindable. The difference between @State and @Bindableis @State owns the instance and can mutate it. But for @Bindable, it may or may not own the instance but cannot mutate it directly but can only provide @Binding-s for its member and those @Binding-s are mutable.

Dollar sign ($) to access the projected value, and accessing members gives us @Binding to that member.

Check out this example to understand more.

@Observable
class Book: Identifiable {
var title = "Sample Book Title"
var isAvailable = true
}

struct LibraryView: View {
@State private var books = [Book(), Book(), Book()]

var body: some View {
List(books) { book in
@Bindable var book = book
TextField("Title", text: $book.title)
}
}
}

In the above example @Bindable var book = book makes the book instance Bindable so it can provide a binding $book.title.

Summing Up

  1. @State : Owns the property & makes the property mutable, two-way connection for read & write
  2. @Binding: Doesn’t own the property but can mutate it, two-way connection for read & write
  3. @Observable: make class instances reactive i.e class instance’s member variables publish changes over time, one-way connection for read only
  4. @Bindable: May or may not own the property but can’t mutate, helps Observables to create Binding for their member variables.

References

  1. https://developer.apple.com/documentation/swiftui/model-data
  2. https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
  3. https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro

--

--

Ajaya Mati

iOS Engineer@PhonePe, IITR@2022 | Swift, UIKit, Swift UI