Understanding data flow in SwiftUI 5
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
- @State
- @Binding
- @Observable
- @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: Bool
and 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, @Observable
don’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 @Bindable
is @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
@State
: Owns the property & makes the property mutable, two-way connection for read & write@Binding
: Doesn’t own the property but can mutate it, two-way connection for read & write@Observable
: make class instances reactive i.e class instance’s member variables publish changes over time, one-way connection for read only@Bindable
: May or may not own the property but can’t mutate, helps Observables to create Binding for their member variables.