Property Wrappers in Swift: How `@State`, `@Binding`, and Custom Wrappers Work


If you’ve used SwiftUI, you’ve already seen @State, @Binding, and @AppStorage sprinkled throughout your views. Those @ symbols aren’t magic — they’re property wrappers, a Swift feature that lets you attach reusable behavior to any property.

You’ll learn what property wrappers are, how built-in wrappers like @State work under the hood, and how to build your own. We won’t dive deep into SwiftUI’s state management system — that has its own dedicated post.

What You’ll Learn

What Is a Property Wrapper?

A property wrapper is a type that adds custom behavior every time a property is read or written. Instead of repeating the same logic everywhere — like clamping a value between a minimum and maximum, or logging every change — you write that logic once in a wrapper and apply it with an @ annotation.

Apple Docs: @propertyWrapper — Swift Standard Library

Think of Remy from Ratatouille hiding under Linguini’s chef hat. Linguini (the property) looks normal from the outside, but Remy (the wrapper) secretly controls what happens every time Linguini moves. The wrapper controls the property’s behavior without the outside world knowing.

How Property Wrappers Work Under the Hood

Every property wrapper is a struct (or class) marked with @propertyWrapper that has a special property called wrappedValue. When you use the @ syntax, Swift rewrites your code behind the scenes.

Here’s a simple wrapper that ensures a string is always stored in uppercase:

@propertyWrapper
struct Uppercased {
    private var stored: String = ""

    var wrappedValue: String {
        get { stored }
        set { stored = newValue.uppercased() }
    }
}

When you use it on a property, Swift transforms your code:

struct PixarCharacter {
    @Uppercased var catchphrase: String
}

var buzz = PixarCharacter()
buzz.catchphrase = "To infinity and beyond"
print(buzz.catchphrase)
TO INFINITY AND BEYOND

Behind the scenes, Swift creates a hidden Uppercased instance and routes all reads and writes through its wrappedValue. You write buzz.catchphrase = "..." — but the wrapper’s set block runs, converting it to uppercase before storing it.

Tip: The wrappedValue property is the only requirement of @propertyWrapper. Everything else — initializers, extra methods, projected values — is optional.

Built-In Property Wrappers

SwiftUI ships with several property wrappers you’ll use constantly. Here’s what the most common ones do at a high level:

@State

@State tells SwiftUI to manage a piece of data for a view. When the value changes, SwiftUI re-renders the view automatically.

import SwiftUI

struct MovieRatingView: View {
    @State private var rating = 0

    var body: some View {
        Text("Rating: \(rating) ⭐")
    }
}

The @State wrapper stores rating outside the struct’s normal memory so it survives view re-creation. Without it, the value would reset every time SwiftUI redraws the view.

@Binding

@Binding creates a two-way connection to a @State property owned by a parent view. It doesn’t own the data — it references someone else’s data.

struct StarButton: View {
    @Binding var rating: Int

    var body: some View {
        Button("Add Star") {
            rating += 1
        }
    }
}

Think of it like the remote control for Buzz Lightyear: pressing buttons on the remote changes Buzz’s behavior directly, because the remote is bound to Buzz.

@AppStorage

@AppStorage reads and writes values to UserDefaults and triggers view updates when they change:

struct SettingsView: View {
    @AppStorage("favoriteMovie") var favorite = "Toy Story"

    var body: some View {
        Text("Favorite: \(favorite)")
    }
}

The string "favoriteMovie" is the UserDefaults key. The wrapper handles reading, writing, and notifying SwiftUI of changes — all in one line.

Writing Your Own Property Wrapper

Let’s build a wrapper that clamps a number between a minimum and maximum value — perfect for things like movie ratings that must stay between 1 and 10.

First, define the wrapper using generics so it works with any Comparable type:

@propertyWrapper
struct Clamped<Value: Comparable> {
    private var value: Value
    let range: ClosedRange<Value>

    var wrappedValue: Value {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }

    init(wrappedValue: Value, _ range: ClosedRange<Value>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

Now use it to keep a movie rating in bounds:

struct Movie {
    var title: String
    @Clamped(1...10) var rating: Int = 5
}

var nemo = Movie(title: "Finding Nemo")
nemo.rating = 15
print("\(nemo.title): \(nemo.rating)")

nemo.rating = -3
print("\(nemo.title): \(nemo.rating)")
Finding Nemo: 10
Finding Nemo: 1

No matter what value you assign, the wrapper clamps it to the 1–10 range. The init(wrappedValue:_:) pattern lets you use the natural = 5 syntax for the default value while also passing the range.

The projectedValue

Property wrappers can expose a projected value accessed with a $ prefix. This is how SwiftUI’s $rating creates a Binding:

@propertyWrapper
struct Tracked<Value> {
    private var value: Value
    private(set) var changeCount = 0

    var wrappedValue: Value {
        get { value }
        set { value = newValue; changeCount += 1 }
    }

    var projectedValue: Int { changeCount }

    init(wrappedValue: Value) {
        self.value = wrappedValue
    }
}
struct Character {
    @Tracked var mood: String = "Happy"
}

var joy = Character()
joy.mood = "Excited"
joy.mood = "Nervous"
print("Mood: \(joy.mood), changed \(joy.$mood) times")
Mood: Nervous, changed 2 times

Accessing joy.mood gives you the wrappedValue. Accessing joy.$mood gives you the projectedValue — in this case, how many times the mood changed.

Common Mistakes

Forgetting wrappedValue in the Initializer

If your wrapper’s init doesn’t apply the same logic as the setter, the initial value can bypass your rules:

// ❌ Initial value skips clamping
@propertyWrapper
struct BadClamped {
    private var value: Int
    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, 0), 100) }
    }
    init(wrappedValue: Int) {
        self.value = wrappedValue // Not clamped!
    }
}
// ✅ Apply the same logic in init
@propertyWrapper
struct GoodClamped {
    private var value: Int
    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, 0), 100) }
    }
    init(wrappedValue: Int) {
        self.value = min(max(wrappedValue, 0), 100)
    }
}

Always clamp, validate, or transform in the initializer too — not just in the setter.

Using @State Outside of SwiftUI Views

@State is designed specifically for SwiftUI views. Using it in a regular struct or class won’t trigger any UI updates:

// ❌ @State does nothing useful here
class MovieManager {
    @State var currentMovie = "Up"
}
// ✅ Use a regular property or @Published instead
class MovieManager: ObservableObject {
    @Published var currentMovie = "Up"
}

@State only works inside a SwiftUI View. For observable classes, use @Published (or the newer @Observable macro in iOS 17+).

What’s Next?

  • Property wrappers attach reusable behavior to properties using @propertyWrapper
  • The only requirement is a wrappedValue property — everything else is optional
  • @State, @Binding, and @AppStorage are built-in SwiftUI wrappers
  • You can build custom wrappers for validation, clamping, logging, and more
  • The $ prefix accesses the projectedValue for extra capabilities

Ready to see how Swift handles operations that can succeed or fail? Head over to The Result Type in Swift to learn about Result<Success, Failure>.