SwiftUI `@State`
In SwiftUI, the @State
property wrapper is used to manage and store a piece of state within a view. This state is local to the view and allows the view to react to changes, re-rendering itself whenever the state changes. Here’s a closer look at how @State
works and how it can be used effectively in your SwiftUI applications.
What is @State
?
@State
is a property wrapper that provides a way to manage mutable state in a SwiftUI view. When a state variable changes, SwiftUI automatically re-renders the parts of the UI that depend on that state.
Basic Example of @State
Consider a simple counter view:
struct Counter: View {
@State private var value = 0
var body: some View {
Button("Increment: \(value)") {
value += 1
}
}
}
In this example, @State
is used to declare a value
property. When the view is first rendered, value
is initialized to 0
. The Button
displays this value and increments it each time it's tapped.
How @State
Works
- State Storage: The
@State
property wrapper stores the state value outside of the view's struct. This is important because views in SwiftUI are structs and are recreated frequently. By storing the state outside of the struct, the state persists across view updates. - Reactivity: When the state changes, SwiftUI re-invokes the
body
property of the view, updating the UI to reflect the new state. - Private to the View:
@State
should be used for state that is private to a single view. It should not be used for sharing state across multiple views.
Manual Creation of State
The same behavior can be implemented without using the @State
property wrapper:
struct Counter: View {
private var _value = State(initialValue: 0)
private var value: Int {
get { _value.wrappedValue }
nonmutating set { _value.wrappedValue = newValue }
}
var body: some View {
Button("Increment: \(value)") {
value += 1
}
}
}
Instead of using @State
, we're now manually creating a State
instance and assigning it to the _value
property. The State(initialValue:)
initializer clearly indicates that the value 0 is just the initial state, which will be used when the counter view’s node is first created in the render tree. Once the node exists, the initial value is no longer relevant, as SwiftUI manages the current value across rerenders.
Additionally, we’ve introduced a computed value
property to simplify state access. This allows us to avoid repeatedly writing _value.wrappedValue
when reading or updating the state; we can simply use value
, which forwards to _value.wrappedValue
behind the scenes. In the view’s body, using wrappedValue
of a state property refers to the persistent state held in the render tree. The @State
property wrapper normally handles this for us by automatically creating the underscored property (storing the actual state value).
Behind the Scenes: What Happens with @State
When the view is first rendered, SwiftUI allocates memory for the state property in what’s called the render tree. The render tree is a representation of the view hierarchy and its state that SwiftUI manages under the hood.
Initial Rendering:
- When the view (in this case,
Counter
) is first created, no node exists in the render tree. - SwiftUI allocates memory for the state (e.g., the
value
property) in the render tree. - The
initialValue
(e.g., 0) is used to initialize this state. - The
wrappedValue
(which we use in the view's body) is now a pointer to that state in the render tree. The initial value is no longer relevant once the state is established in memory.
Re-Rendering:
When the button is tapped and the state is updated (e.g., incrementing value
), SwiftUI detects that the body of the view depends on the state, so it re-executes the body
method. This causes the view to be reconstructed with the new state value (1 instead of 0).
The process follows these steps:
- The state value (which is stored in the render tree) is updated.
- SwiftUI re-renders the
Counter
view, and the body gets re-executed, creating a new button with the updated value (1). - The UI reflects the change by updating the button’s title.
Initializer Exposing State via the Initializer (Gotcha)
In this example, we’re trying to expose a state property (value
) through the view's initializer:
struct Counter: View {
@State private var value: Int
init(value: Int = 0) {
_value = State(initialValue: value)
}
var body: some View {
Button("Increment: \(value)") {
value += 1
}
}
}
#Preview{
Counter(value:5)
}
Here’s what happens:
- The
@State
property is used to manage internal state that SwiftUI keeps track of during re-renders. - We initialize
value
by assigning the initial value from the initializer. TheState(initialValue:)
part sets the value to whatever we pass when creating the view. However, this only sets the initial value, not the current state of the view. Once the view has been rendered, this value is handled entirely by SwiftUI, and passing in a new initial value has no effect on the state of the view.
Gotcha: what if we pass in the another value from the initializer ?
struct CounterInitialInit: View {
@State private var value: Int
init(value: Int = 0) {
print("init value is \(value)")
_value = State(initialValue: value)
}
var body: some View {
let _ = Self._printChanges()
Text("CounterInitialInit: \(value)")
}
}
struct CounterInitialInitParent:View {
@State var value = 5
var body: some View {
CounterInitialInit(value:value)
let _ = Self._printChanges()
Button("Increment") {
value += 1
}
}
}
#Preview{
CounterInitialInitParent()
}
The State(initialValue:)
part sets the value to whatever we pass when creating the view. However, this only sets the initial value, not the current state of the view. Once the view has been rendered, this value is handled entirely by SwiftUI, and incrementing the value calls the initializer from the CounterInitialInit
however the value in the view doesn’t change at all therefore it is recommended declaring all @State
properties as private.
What if we initialize the following way?
Code Example
For example, here’s what happens when we initially render the counter view with a value of 0 and then update it to a value of 5,
struct Counter: View {
@State private var value = 0
init(value: Int) {
self.value = value // This won't work as expected!
}
var body: some View {
VStack {
Text("Current Value: \(value)")
Button("Increment") {
value += 1
}
}
}
}
struct ContentView: View {
var body: some View {
Counter(value: 5) // Initializing with 5, but it won't be reflected!
}
}
What You Expect to Happen:
- You initialize
Counter
withvalue: 5
. - You expect the
Text
to show"Current Value: 5"
when the view appears.
What Actually Happens:
- The
Text
shows"Current Value: 0"
, even though you passed5
in the initializer. - The
value
state starts at0
, and any changes to the passed value (e.g.,5
) are ignored.
Why This Happens:
- State Management: When you use
@State
, SwiftUI manages the internal state of the view. The@State
value (value
in this case) is stored in the render tree, and SwiftUI controls when and how it gets updated. - Identity and Render Tree: SwiftUI assigns identity to views when they are inserted into the view hierarchy. Until the view is actually placed in the hierarchy, SwiftUI doesn’t track its state. When the
self.value = value
line runs, SwiftUI hasn’t yet connected this view to the render tree, so the assignment doesn't impact the view’s actual state. This is why@State
values must be initialized through the property wrapper itself (like@State private var value = 0
) rather than in the initializer. - Initializer Timing: In the initializer, the statement
self.value = value
translates toself._value.wrappedValue = value
. However, at this point in the view lifecycle, the@State
property hasn’t yet been fully set up. The view doesn't have an identity in SwiftUI's render tree, soself.value = value
doesn't affect the internal state that SwiftUI manages.
The Correct Approach
If you want to control the initial state from outside, you could use a different property wrapper like @Binding
or @ObservedObject
, which are designed to manage state passed from parent views.
For example, using @Binding
:
struct Counter: View {
@Binding var value: Int
var body: some View {
VStack {
Text("Current Value: \(value)")
Button("Increment") {
value += 1
}
}
}
}
struct ContentView: View {
@State private var value = 5 // Control state here
var body: some View {
Counter(value: $value) // Binding the state to the child view
}
}
In this case, the state is managed by the parent (ContentView
), and the child (Counter
) view can access and modify it through a @Binding
, which is the correct way to pass state between views in SwiftUI.
Structural Identity in SwiftUI
SwiftUI relies on structural identity to manage the state of views. This means that views get their identity based on their position in the view hierarchy. Here’s why setting value
in the initializer doesn’t work:
- Views gain identity only when they are placed in the view tree.
- At the time the initializer is called, the view doesn’t yet have identity, and therefore SwiftUI cannot manage its state.
struct ContentView: View {
let counter = Counter()
var body: some View {
VStack {
counter
counter
}
}
}
In this example, the Counter
view’s initializer runs once when the counter
is created. However, since the view hasn’t been placed in the view hierarchy yet, it has no identity, and its state is not managed by SwiftUI at this point. Once the view is placed in the hierarchy (in VStack
), it gains identity, and the state property (value
) then references the correct state in the render tree.
Key Takeaways
- @State is for private view state: It should be managed internally, and its lifecycle is tied to the view’s identity in the render tree.
- Initial values vs current state: Passing an initial value only sets the starting value for the state and doesn’t affect the current state once the view is rendered so setting another value through the initializer only changes the inital value and not the wrapped value that swiftUI actually uses.
- View identity is critical: The state of a view is tied to its position in the view hierarchy, which is why you can’t modify state reliably in the initializer even if let’s say you set the wrapped property somehow cause at the time the initializer runs, the view doesn’t yet have identity.
By understanding how @State
interacts with the render tree and the view hierarchy, you can avoid common pitfalls and ensure your SwiftUI apps are both performant and predictable.