SwiftUI `@State`

abdul ahad
8 min readSep 5, 2024

--

Photo by Johannes Krupinski on Unsplash

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

  1. 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.
  2. Reactivity: When the state changes, SwiftUI re-invokes the body property of the view, updating the UI to reflect the new state.
  3. 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:

  1. When the view (in this case, Counter) is first created, no node exists in the render tree.
  2. SwiftUI allocates memory for the state (e.g., the value property) in the render tree.
  3. The initialValue (e.g., 0) is used to initialize this state.
  4. 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:

  1. The state value (which is stored in the render tree) is updated.
  2. SwiftUI re-renders the Counter view, and the body gets re-executed, creating a new button with the updated value (1).
  3. 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:

  1. The @State property is used to manage internal state that SwiftUI keeps track of during re-renders.
  2. We initialize value by assigning the initial value from the initializer. 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 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 CounterInitialInithowever 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 with value: 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 passed 5 in the initializer.
  • The value state starts at 0, and any changes to the passed value (e.g., 5) are ignored.

Why This Happens:

  1. 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.
  2. 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.
  3. Initializer Timing: In the initializer, the statement self.value = value translates to self._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, so self.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

  1. @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.
  2. 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.
  3. 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.

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet