iosNudge

Explain @State, @Binding, @Bindable and @Environment. When do you use each?

MediumAsked oftenSwiftUI 2 min read

TL;DR

Every piece of data needs one source of truth. Ask: "who owns it?"

  • The view owns it: @State.
  • A parent owns it and the child must change it: @Binding.
  • It is an @Observable object and you need bindings to its properties: @Bindable.
  • It is shared by many views down the tree: @Environment.

@State: the view owns it

struct Counter: View {
    @State private var count = 0

    var body: some View {
        Button("Count: \(count)") { count += 1 }
    }
}

SwiftUI stores the value outside the struct and keeps it while the view has the same identity. Make @State private. Set its first value in the declaration, not from the parent's init.

@State also owns @Observable objects (iOS 17+):

struct ProfileScreen: View {
    @State private var model = ProfileModel()
    var body: some View { ProfileForm(model: model) }
}

@Binding: read and write someone else's data

struct ToggleRow: View {
    let title: String
    @Binding var isOn: Bool

    var body: some View {
        Toggle(title, isOn: $isOn)
    }
}

struct Settings: View {
    @State private var notifications = true
    var body: some View {
        ToggleRow(title: "Notifications", isOn: $notifications)
    }
}

The $ prefix gives you the binding (the "projected value"). A binding does not store data. It is a getter and a setter pointing to the real owner.

@Bindable: bindings to an @Observable object

@Observable final class ProfileModel {
    var name = ""
    var bio = ""
}

struct ProfileForm: View {
    @Bindable var model: ProfileModel

    var body: some View {
        Form {
            TextField("Name", text: $model.name)
            TextField("Bio", text: $model.bio)
        }
    }
}

If the view only reads the model, a plain let model: ProfileModel is enough. Use @Bindable only when you need $.

@Environment: shared values from above

@main
struct MyApp: App {
    @State private var session = Session()
    var body: some Scene {
        WindowGroup {
            RootView().environment(session)
        }
    }
}

struct AccountBadge: View {
    @Environment(Session.self) private var session
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Text(session.userName)
    }
}

Use it for app-wide things: the session, theme, dismiss, colorScheme, locale. Custom keys are easy with the @Entry macro:

extension EnvironmentValues {
    @Entry var accentStyle: Color = .blue
}

The old way (still asked)

Before iOS 17, reference types used ObservableObject and @Published:

iOS 17+ (Observation)Before iOS 17 (Combine based)
@State var model = Model()@StateObject var model = Model()
let model: Model / @Bindable@ObservedObject var model: Model
@Environment(Model.self)@EnvironmentObject var model: Model

The big bug in the old world: using @ObservedObject to create an object. The view recreates it on every parent update and state is lost. @StateObject fixes that.

Common mistakes

  • Two sources of truth: copying a parent's value into child @State. The child stops seeing updates.
  • Passing @State values into init and expecting them to reset. @State keeps its first value.
  • Putting everything in the environment. It hides dependencies.

Common follow-up questions

  • Why must @State be private? It is owned by this view. Outside code should use a binding.
  • Can @State hold a class? Yes, with @Observable classes. SwiftUI keeps one instance for the view's lifetime.
  • What is Binding.constant(_:)? A fake binding for previews.
  • What is @AppStorage? A property wrapper that reads and writes UserDefaults and updates the view.
  • What happens when @State changes? SwiftUI calls body again for views that read it and updates only what changed.