State management in SwiftUI

Updated Mar 17, 2023#swiftui#comparison

In SwiftUI, you mutate some state and the state work as a source of truth from which you derive your view. This is where SwiftUI, declarative syntax shine. You describe your view given the current state. And this is also how SwiftUI helps you manage the complexity of UI development allowing you to write beautiful and correct interfaces.

SwiftUI ships with a handful of property wrappers that enable us to declare exactly how our data is observed, rendered and mutated by our views.

  • State, StateObject
  • Binding, ObservedObject
  • EnvironmentObject, Environment
  • AppStorage, SceneStorage

Binding

Use a binding to create a two-way connection between a property that stores data, and a view that displays and changes the data. A binding connects a property to a source of truth stored elsewhere, instead of storing data directly.

struct PlayButton: View {
  @Binding var isPlaying: Bool

  var body: some View {
    Button(isPlaying ? "Pause" : "Play") {
      isPlaying.toggle()
    }
  }
}

struct PlayerView: View {
  var episode: Episode
  @State private var isPlaying: Bool = false

  var body: some View {
    VStack {
      Text(episode.title)
        .foregroundStyle(isPlaying ? .primary : .secondary)
      PlayButton(isPlaying: $isPlaying) // Pass a binding.
    }
  }
}

State

SwiftUI manages the storage of a property that you declare as state. When the value changes, SwiftUI updates the parts of the view hierarchy that depend on the value. Use state as the single source of truth for a given value stored in a view hierarchy.

A State instance isn’t the value itself; it’s a means of reading and writing the value. To access a state’s underlying value, refer to it by its property name, which returns the wrappedValue property value.

struct PlayButton: View {
  @State private var isPlaying: Bool = false

  var body: some View {
    Button(isPlaying ? "Pause" : "Play") {
      isPlaying.toggle()
    }
  }
}

Don’t initialize a state property of a view at the point in the view hierarchy where you instantiate the view, because this can conflict with the storage management that SwiftUI provides.

Always declare state as private, and place it in the highest view in the view hierarchy that needs access to the value. Then share the state with any child views that also need access, either directly for read-only access, or as a binding for read-write access.

StateObject

A property wrapper that instantiates and stores an observable object in state, and holds on to its value even when the view is invalidated and redrawn.

class Counter: ObservableObject {
    @Published var count = 0
}

struct ContentView: View {
    @StateObject var counter = Counter()

    var body: some View {
        VStack {
            Text("Count: \(counter.count)")
            Button("Increment") {
                counter.count += 1
            }
        }
        .font(.system(size: 20))
    }
}

ObservedObject

A property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.

Unlike StateObject, ObservedObject does not persist the object in state. Objects are only assigned to ObservedObject, they should be initialized and persistent by an ancestor view.

class GameProgress: ObservableObject {
    @Published var level = 1
}

struct LevelView: View {
    @ObservedObject var progress: GameProgress
    
    var body: some View {
        Button("Increase Level") {
            progress.level += 1
        }
    }
}

struct GameView: View {
    @StateObject var progress = GameProgress()
    
    var body: some View {
        VStack {
            Text("Your current level is \(progress.level)")
            LevelView(progress: progress)
        }
    }
}

Do not use ObservedObject to create instances of your object. If that’s what you want to do, use StateObject instead.

EnvironmentObject

An environment object invalidates the current view whenever the observable object changes. If you declare a property as an environment object, be sure to set a corresponding model object on an ancestor view by calling its environmentObject(_:) modifier.

EnvironmentObject differs from ObservedObject in that it receives the object to observe at runtime, from the view’s environment, whereas ObservedObject receives it directly either by the immediate parent view or by an initial value while declaring it.

class DataModel: ObservableObject {
  let message: String = "This is a message passed through views using EnvironmentObject"
}

struct MainView: View {
  @StateObject var dataModel = DataModel()

  var body: some View {
    ChildView()
      .environmentObject(dataModel)
  }
}

struct ChildView: View {
  @EnvironmentObject var dataModel: DataModel

  var body: some View {
    VStack {
      Text(dataModel.message)
        .padding()

      Text("Child view")
        .foregroundColor(.gray)
    }
    .font(.system(size: 17))
  }
}

Enviroment

Use the Environment property wrapper to read a value stored in a view’s environment. Indicate the value to read using an EnvironmentValues key path in the property declaration.

You can use this property wrapper to read — but not set — an environment value. SwiftUI updates some environment values automatically based on system settings and provides reasonable defaults for others.

struct FooView: View {
  @Environment(\.horizontalSizeClass) private var horizontalSizeClass
  @Environment(\.colorScheme) private var colorScheme
  @Environment(\.calendar) private var calendar
  @Environment(\.locale) private var locale
  
  var body: some View {
    VStack {
      Text(locale.description)
      Text(calendar.description)
      Text(horizontalSizeClass == .compact ? "Compact": "Regular")
      Text(colorScheme == .dark ? "Dark mode" : "Light mode")
    }
  }
}

You can override some of these, as well as set custom environment values that you define, using the environment(_:_:) view modifier, which affects the given view, as well as that view’s descendant views. It has no effect outside the view hierarchy on which you call it.

SceneStorage

You use SceneStorage when you need automatic state restoration of the value. It works very similar to State, except its initial value is restored by the system if it was previously saved, and the value is shared in the same scene.

The system manages the saving and restoring of SceneStorage on your behalf. The underlying data that backs SceneStorage is not available to you, so you must access it via the SceneStorage property wrapper. The system makes no guarantees as to when and how often the data will be persisted.

struct FooView: View {
  @SceneStorage("fooview.bar") private var bar: Int = 1
}

If the scene is explicitly destroyed, the data is also destroyed. Do not use SceneStorage with sensitive data.

AppStorage

A property wrapper type that reflects a value from UserDefaults and invalidates a view on a change in value in that user default. Use it to save small amounts of data like user settings that you want to persist across app launches:

struct FooView: View {
  @AppStorage("com.example.fooview.bar") private var bar: Bool = true
}

The AppStorage and SceneStorage can store only primitive types like Bool, Int, Double, String, URL and Data. All other types must be encoded in Data object and conforms to the Codable protocol.