Inside SwiftUI @State property wrapper
This article is part 1 of the upcoming series about SwiftUI’s states and bindings. Though none of us has got an exclusive access to the SwiftUI source code, it’s always enthralling to try to take a peek at the details of the SwiftUI essentials. Let’s drill down together!
What is @State?
@State
is a property wrapper that should be used for private state value changes inside the view. Consider using @State
for the property declared inside the view. @State
property is meant to be changed or/and displayed inside the view. The main rule is to use @State
only for storage that is local to a view and its subviews.
Let’s look at a typical scenario @State
property can be used:
import SwiftUI
// This is an example of the basic @State property wrapper usage
struct ContentView: View {
// MARK: - Properties
@State var count: Int = 0
// MARK: - Body
var body: some View {
Button(action: {
count += 1
}, label: {
buttonContent
})
}
private var buttonContent: some View {
HStack {
Image(systemName: "arrow.up")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Count: \(count)")
.foregroundStyle(.white)
.padding(.horizontal)
}
.padding()
.background {
Color.black
.cornerRadius(16)
}
}
}
Here I declare @State
variable count
that has initial value 0. Every time on tapping a button the count
variable will be incremented by 1. This is how we set the value. Also, the button title will be the one to reflect this variable changes — we are interpolating the title with the value from the count @State
property.
But now, let’s think more about the magic of the @State
variables in SwiftUI. How can the value property be changed, i.e mutated if the SwiftUI view is a struct which is not a reference type but a value type? And how do we change the value this way inside the Button
’s closure that is not a mutable context? Let’s try to remove the @State
property wrapper and see the error the compiler gives us.
⚠️ Left side of mutating operator isn’t mutable: ‘self’ is immutable
Obviously, self indeed is immutable. So how could it work at all then?
The answer lies inside the State<Value>
struct provided by Apple. To replicate the property wrapper, we can use a State
value for the count
property. To write the count
property, we will need to do it like that:
struct ContentView: View {
// MARK: - Properties
private var count = State(initialValue: 0)
// MARK: - Body
var body: some View {
Button(action: {
count.wrappedValue += 1
}, label: {
buttonContent
})
}
private var buttonContent: some View {
HStack {
Image(systemName: "arrow.up")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Count: \(count.wrappedValue)")
.foregroundStyle(.white)
.padding(.horizontal)
}
.padding()
.background {
Color.black
.cornerRadius(16)
}
}
}
Looks a bit bulky, doesn’t it? Let’s try to create a computed property to avoid using count.wrappedValue
each and every time.
struct ContentView: View {
// MARK: - Properties
private var count = State(initialValue: 0)
private var _count: Int {
get {
count.wrappedValue
}
set {
// compiler error here
count.wrappedValue = newValue
}
}
// MARK: - Body
var body: some View {
Button(action: {
_count += 1
}, label: {
buttonContent
})
}
private var buttonContent: some View {
HStack {
Image(systemName: "arrow.up")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Count: \(_count)")
.foregroundStyle(.white)
.padding(.horizontal)
}
.padding()
.background {
Color.black
.cornerRadius(16)
}
}
}
But when setting the _count.wrappedValue
, the compiler again gives us the same error.
⚠️ Left side of mutating operator isn’t mutable: ‘self’ is immutable
How come it can be mutable inside the Button
’s action closure, but gives an error when assigning a value inside set? To get close to revealing this secret, we must look into what is the wrappedValue
itself:
public var wrappedValue: Value { get nonmutating set }
So setting wrappedValue
is possible because nonmutating set
is used. This is an example that will compile without a hitch:
import SwiftUI
struct ContentView_StateUnderTheHood: View {
// MARK: - Properties
private var _count = State(initialValue: 0)
private var count: Int {
get {
_count.wrappedValue
}
nonmutating set {
_count.wrappedValue = newValue
}
}
// MARK: - Body
var body: some View {
Button(action: {
count += 1
}, label: {
buttonContent
})
}
private var buttonContent: some View {
HStack {
Image(systemName: "arrow.up")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Count: \(count)")
.foregroundStyle(.white)
.padding(.horizontal)
}
.padding()
.background {
Color.black
.cornerRadius(16)
}
}
}
Here, we can think of nonmutating
as a trick around standard value type mutation. Value types are immutable, and to change the value we need to use the mutating keyword (think mutating functions in structs).
All in all, when using the wrappedValue
of the State
property count
, what it really has to do with is a reference to the value, as a pointer to the actual value of the state property.
So the @State
property wrapper will do it all for us for free. It creates both the _count
and the count
properties. And it allows to have both initial value (which is 0) and the wrappedValue
we are interacting with by hitting the button and incrementing the count.
Passing value to the @State var in init
Another interesting part is trying to pass a value to the @State
property count
from the outside. Simply passing Int value
will leave the compiler happy, but are we happy?
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
// Passing 1 to the init
ContentView(count: 1)
}
}
}
struct ContentView: View {
// MARK: - Properties
@State var count: Int = 0
// MARK: - Init
// Despite it's compiled, the button's title will be unchanged
// showing the "Count: 0" text
init(count: Int) {
self.count = count
}
}
Despite this code is compiled, the result is unsatisfactory. We are setting the initial value
to be 1
from the outside. However, when the button appears on the screen, its title says “Count: 0
”.
struct ContentView: View {
// MARK: - Properties
@State var count: Int = 0
// MARK: - Init
// Initialising State property inside the init will ensure
// count value is passed correctly and the button shows the right title
init(count: Int) {
_count = State(initialValue: count)
}
}
The reason lies inside the essence of the SwiftUI itself. When this initialiser runs, the view doesn't have an identity yet. But when it does — it will show the initial value of the state property, which is 0.
Though in some cases initialising the @State
property may seem reasonable, let me emphasise it once again: @State
is used for the property declared inside the view. And it is very natural for it to be initialised, changed, and displayed inside the view.
In the next articles, I am going to cover @Binding
, @StateObject
, and @ObservableObject
, as well as some very exciting changes to them brought by iOS 17.
Subscribe and stay tuned. Happy coding!