Abstracting SwiftUI state with scopes

One of the biggest pain points for me in SwiftUI has always been creating local state in a way that can be mocked for previews or controlled from outside the view. For example, supplying a pre-populated navigation path to a screen when it opens and updating it from a deep link. Or an observable model for a view, connected to a live service in the app but populated with sample data in previews.

Consider an app for controlling lights at home. We have a model representing a room and a view that displays it.

@MainActor @Observable final class Room: Identifiable {
  let id: UUID

  var name: String
  
  ...
}

struct RoomView: View {
  var body: some View { ... }
}

RoomView needs a Room instance. In the app, that instance may come from some kind of a store which creates it, configures it for a certain room, and connects it to live updates. In a preview, there needs to be a way to supply a sample room. How should that be structured and where should that setup live?

I’ve been experimenting with custom property wrappers and environment injection to handle this. The result is a small library I call ScopedState. With it, a view can declare what it needs by selecting a connection from a predefined scope.

struct RoomView: View {
  @ScopedState(\RoomScope.room) private var room

  var body: some View {
    Text(room.name)
  }
}

ScopedState uses standard SwiftUI building blocks like @State, @Environment, and DynamicProperty. Parent views inject scope instances into the environment so that the ScopedState property wrapper can resolve its values as part of SwiftUI’s state lifecycle.

Let’s build a small version from scratch to see how everything fits together.

The SwiftUI way

A natural place to start is to have the parent provide an instance to the view.

struct RoomView: View {
  let room: Room
  ...
}

Having the room supplied makes RoomView straightforward to implement, but the parent still has to get the room instance somewhere. Say we have a RoomStore object in our app that can create Room instances from their IDs. The parent view, HomeView, has the IDs and can access the store through the environment, so let’s try resolving the rooms there.

struct HomeView: View {
  var roomIDs: [Room.ID]

  @Environment(RoomStore.self) private var roomStore

  var body: some View {
    ForEach(roomIDs, id: \.self) { roomID in
      RoomView(room: roomStore.room(for: roomID)) // ⚠️
    }
  }
}

This works, but creating Room instances is tied to evaluating HomeView’s body, which can happen for reasons unrelated to the rooms it displays. Each evaluation will call the store again, potentially repeating expensive object creation logic. It would be better to retain the instance across evaluations.

To do that, we need to make RoomView hold on to the instance for the duration of its lifetime, so let’s declare a @State variable for it. We'll also try moving room creation inside the view itself, to the initializer.

struct HomeView: View {
  var roomIDs: [Room.ID]

  var body: some View {
    ForEach(roomIDs, id: \.self) { roomID in
      RoomView(id: roomID)
    }
  }
}

struct RoomView: View {
  @State private var room: Room

  init(id: Room.ID) {
    self.room = RoomStore.global.room(for: id) // ⚠️
  }

  ...
}

There are a few catches with this approach. First, the environment isn’t yet available in the initializer. We can no longer access the injected RoomStore, so this example falls back to a global instance.

We haven’t avoided repeated work, either. Every initializer call still asks the store for a Room instance. @State keeps the first value for the lifetime of the view, but any subsequent values won’t be used. This also means that passing a different room ID can leave the view still holding the previous room (although ForEach saves us in this particular example, since it ties its content view identity to the provided identifier).

What if we move the lookup out of the initializer? We need a place where the environment is available and where we can respond to changes to the ID. Let’s try onChange(of:initial:).

struct RoomView: View {
  let id: Room.ID
  
  @State private var room: Room? // ⚠️

  @Environment(RoomStore.self) private var roomStore

  var body: some View {
    ZStack {
      if let room {
        Text(room.name)
      }
    }
    .onChange(of: id, initial: true) {
      guard room?.id != id else { return }
      room = roomStore.room(for: id)
    }
  }
}

This form is close to what I’d consider best practice in plain SwiftUI: we’re resolving the store from the environment, keeping the resolved room as @State, reacting to ID changes. Unrelated body evaluations no longer cause repeated lookups.

But RoomView now has quite a bit of setup code alongside its presentation logic. It needs to know about RoomStore, perform the lookup, and know when to update it. room starts out as nil because it needs the callback to run first, and the body has to account for a room that isn’t available yet, even though our store can supply the room synchronously.

DynamicProperty to the rescue

This setup doesn’t have to live in the view. We can hide the complexity by moving it into a custom property wrapper that conforms to DynamicProperty and participates in SwiftUI’s update cycle.

@propertyWrapper struct RoomState: DynamicProperty {
  private final class Coordinator {
    var room: Room?
  }

  let id: Room.ID

  @State private var coordinator = Coordinator()

  @Environment(RoomStore.self) private var roomStore

  var wrappedValue: Room {
    if let room = coordinator.room {
      room
    } else {
      preconditionFailure("State was read before DynamicProperty.update()")
    }
  }

  func update() {
    if id != coordinator.room?.id {
      coordinator.room = roomStore.room(for: id)
    }
  }
}

A dynamic property can use all the built-in property wrappers just like a view, and it gets an update call before SwiftUI evaluates the view’s body. We store the room as an optional internally, but the view doesn’t need to handle that: by the time its body runs, the room is guaranteed to be set.

We use a coordinator because update is not supposed to modify state directly when it runs, and reassigning @State there will produce a runtime warning. Instead, state holds a stable reference to the coordinator, and we assign the room to a property of that object.

With that in place, RoomView can be greatly simplified.

struct RoomView: View {
  @RoomState var room: Room

  init(id: Room.ID) {
    _room = .init(id: id)
  }

  var body: some View {
    Text(room.name)
  }
}

Connections and scopes

We’ve removed RoomStore from RoomView, but RoomState still depends on it. All it really needs is a way to obtain a Room given a Room.ID, so let’s make this dependency replaceable. A preview could then supply sample rooms without mocking out RoomStore.

To make this abstraction more general, let’s call that a connection, with the ID serving as its configuration.

struct ConfiguredConnection<Value, Configuration: Equatable> {
  var resolve: (Configuration) -> Value
}

For now, a connection is just a recipe for obtaining a value. Our existing store can implement this, but so can anything else that can supply a room given its ID. That gives us the type for the dependency: ConfiguredConnection<Room, Room.ID>. Next, we need somewhere to put it.

Rather than injecting individual connections into the environment, we’ll group related ones into a scope. A HomeScope can describe the state available when working with a home. We’ll start with just the connection to its rooms.

struct HomeScope {
  let room: ConfiguredConnection<Room, Room.ID>
}

The code supplying the scope gets to choose how those connections work. Suppose we have an AppContainer that creates a RoomStore and an AppView that injects it into the environment.

final class AppContainer {
  let roomStore = RoomStore()
}

struct AppView: View {
  @State private var container = AppContainer()
  
  var body: some View {
    HomeView(roomIDs: container.roomStore.allRoomIDs)
      .environment(container.roomStore)
  }
}

We can give AppContainer a computed property that assembles a HomeScope instead.

final class AppContainer {
  ...
  
  var homeScope: HomeScope {
    .init(
      room: .init(
        resolve: { [roomStore] id in roomStore.room(for: id) }
      )
    )
  }
}

When creating the scope instance, this container captures and uses RoomStore for the room lookup, but another container could construct the same scope using a completely different implementation.

On the view side, we’ll replace RoomStore injection into the environment with a custom view modifier.

struct AppView: View {
  @State private var container = AppContainer()
  
  var body: some View {
    HomeView(roomIDs: container.roomStore.allRoomIDs)
      .container(container, scope: \.homeScope)
  }
}

We pass the container and a key path to a scope that it provides, and the modifier makes that scope available to descendants via the environment. Let’s build the machinery behind that modifier.

First, in order to use SwiftUI’s type-based environment injection, we need an Observable object to hold the scope instance.

@Observable final class ConfiguredConnectionStorage<Value> {
  var value: Value?
}

The object gives us stable storage for the scope. Its generic argument also makes it possible to distinguish between different injected scopes without defining a new storage class or environment key for each one.

Next, we'll make RoomState use this object instead of RoomStore.

@propertyWrapper struct RoomState: DynamicProperty {
  @Environment(ConfiguredConnectionStorage<HomeScope>.self) private var scope

  ...

  func update() {
    if id != coordinator.room?.id {
      coordinator.room = scope.value?.room.resolve(id)
    }
  }
}

The lookup is now performed by resolving a connection for a known scope. RoomState still needs to provide the room’s ID and retain the result, but it no longer calls into a particular store.

The injection side needs a little more care. If we evaluate container.homeScope every time AppView.body runs, we’ll keep creating and assigning new scope values containing new closures, even though nothing about the source may have changed.

Instead, we’ll establish a rule: the same container object and the same key path provide the same scope definition. We’ll evaluate that property the first time we need it, then keep the result until either the container instance or the key path changes.

This doesn’t require connected values to stay constant. A connection can return a mutable room, it’s the definition of how to obtain that room that we’re keeping stable.

We can implement this with another dynamic property, using the same coordinator pattern as RoomState.

@propertyWrapper private struct ContainerScopeProvider<Container: AnyObject, Scope>: DynamicProperty {
  private final class Coordinator {
    let storage = ConfiguredConnectionStorage<Scope>()

    private var container: Container?

    private var keyPath: KeyPath<Container, Scope>?

    func update(container: Container, keyPath: KeyPath<Container, Scope>) {
      guard self.container !== container || self.keyPath != keyPath else {
        return
      }

      storage.value = container[keyPath: keyPath]

      self.container = container
      self.keyPath = keyPath
    }
  }

  @State private var coordinator = Coordinator()

  let container: Container

  let keyPath: KeyPath<Container, Scope>

  func update() {
    coordinator.update(container: container, keyPath: keyPath)
  }

  var wrappedValue: ConfiguredConnectionStorage<Scope> {
    coordinator.storage
  }
}

As before, we use an update call as an opportunity to resolve or update the stored value just before it is needed. The modifier simply injects the storage object from the property wrapper into the environment.

struct ContainerScopeModifier<Container: AnyObject, Scope>: ViewModifier {
  @ContainerScopeProvider<Container, Scope> private var scope: ConfiguredConnectionStorage<Scope>

  init(container: Container, scope keyPath: KeyPath<Container, Scope>) {
    self._scope = ContainerScopeProvider(container: container, keyPath: keyPath)
  }

  func body(content: Content) -> some View {
    content
      .environment(scope)
  }
}

Finally, a view extension gives us the .container spelling used above.

extension View {
  func container<Container: AnyObject, Scope>(_ container: Container, scope: KeyPath<Container, Scope>) -> some View {
    modifier(ContainerScopeModifier(container: container, scope: scope))
  }
}

The modifier is generic over the container, so it knows its concrete type, but that type doesn’t change what's injected into the environment. RoomState asks for a generic storage containing a HomeScope, regardless of which container provided it.

RoomView stays the same. To supply sample rooms in a preview, we can now replace the container without changing either the view or RoomState.

Making it generic

RoomState no longer depends on a particular store, but it is still a wrapper custom-made for Room types with built-in knowledge of which connection to use and which scope to look in. We don’t want to write a new wrapper each time we need a different connection.

Let’s make RoomState generic over the scope, configuration, and value, and rename it ScopedState to support this abstracted role. All three generic parameters can be inferred from a single fully qualified key path to a connection, and by taking an Equatable configuration instance, the wrapper can detect when the value should be recreated.

We’ll rename ConfiguredConnectionStorage to ScopedStateStorage and reuse it to hold both the resolved value and the injected scope.

@propertyWrapper struct ScopedState<Scope, Configuration: Equatable, Value>: DynamicProperty {
  private final class Coordinator {
    let storage = ScopedStateStorage<Value>()

    var configuration: Configuration?
  }

  @State private var coordinator = Coordinator()

  @Environment(ScopedStateStorage<Scope>.self) private var scope

  let keyPath: KeyPath<Scope, ConfiguredConnection<Value, Configuration>>

  let configuration: Configuration

  var wrappedValue: Value {
    if let value = coordinator.storage.value {
      value
    } else {
      preconditionFailure("State was read before DynamicProperty.update()")
    }
  }

  var storage: ScopedStateStorage<Value> {
    coordinator.storage
  }

  func update() {
    if coordinator.configuration != .some(configuration), let scope = scope.value {
      coordinator.storage.value = scope[keyPath: keyPath].resolve(configuration)
      coordinator.configuration = configuration
    }
  }
}

The wrapper resolves its value from a scope in the environment, using the provided key path. It will do so on the first update and whenever its configuration changes, but other updates leave the stored value alone.

RoomView can use the new wrapper without otherwise changing how it works.

struct RoomView: View {
  @ScopedState<HomeScope, Room.ID, Room> private var room: Room

  init(id: Room.ID) {
    _room = .init(keyPath: \HomeScope.room, configuration: id)
  }

  var body: some View {
    Text(room.name)
  }
}

But the ID still only serves to configure the connection. The view otherwise doesn’t care about it, since it is not used in the body. Could we move that configuration out of the view too?

We can do that by introducing a new RoomScope to represent a particular room. HomeScope will now provide a connection to RoomScope, configured by a room ID. Once that scope is injected into the environment, descendant views can ask for the selected room without supplying the ID again.

For that, we need a way to define a connection that doesn’t require configuration. A new marker type lets us reuse ConfiguredConnection, and with a quick convenience initializer for ScopedState, it can be selected automatically.

struct EmptyConfiguration: Equatable { }

typealias Connection<Value> = ConfiguredConnection<Value, EmptyConfiguration>

extension ScopedState where Configuration == EmptyConfiguration {
  init(_ keyPath: KeyPath<Scope, Connection<Value>>) {
    self.init(keyPath: keyPath, configuration: EmptyConfiguration())
  }
}

Our scope definitions now look like this:

struct HomeScope {
  let roomScope: ConfiguredConnection<RoomScope, Room.ID>
}

struct RoomScope {
  let room: Connection<Room>
}

The difference between them is what the caller needs to know: resolving HomeScope.roomScope requires an ID, but resolving RoomScope.room doesn’t, because the scope already represents a particular room. Since there is a clear hierarchical relationship between the two, we can say that RoomScope is a child scope of HomeScope.

The container implements this by looking up the room when the child scope is resolved, then capturing that instance in its connection.

final class AppContainer {
  ...
  
  var homeScope: HomeScope {
    .init(
      roomScope: .init { [roomStore] id in
         let room = roomStore.room(for: id)

         return RoomScope(
           room: .init { _ in room }
         )
       }
    )
  }
}

That gives us a way to create a RoomScope, but we still need to retain it and make it available to views. We’ve just built the machinery for the first part: a child scope can be created using the connection from a parent scope, soScopedState can resolve and retain it like any other value.

We’ll use it inside a view modifier, which injects the resulting storage into the environment:

struct ScopeModifier<ParentScope, Configuration: Equatable, Scope>: ViewModifier {
  @ScopedState<ParentScope, Configuration, Scope> private var scope: Scope

  init(scope: ScopedState<ParentScope, Configuration, Scope>) {
    _scope = scope
  }

  func body(content: Content) -> some View {
    content
      .environment(_scope.storage)
  }
}

extension View {
  func scope<ParentScope, Configuration: Equatable, Scope>(
      _ keyPath: KeyPath<ParentScope, ConfiguredConnection<Scope, Configuration>>,
      configuration: Configuration
  ) -> some View {
    modifier(ScopeModifier(scope: ScopedState(keyPath: keyPath, configuration: configuration)))
  }
}

Notice that the modifier doesn't reach for scope, but  _scope.storage, which is the storage object the property wrappers uses to hold the resolved value. Since we made it use the same type as the one used for environment lookup, we can mirror what the .container does and inject it into the environment for the descendantScopedState properties.

The parent can now establish the room’s scope where it creates RoomView:

RoomView()
    .scope(\HomeScope.roomScope, configuration: roomID)

And RoomView only needs to select the connection:

struct RoomView: View {
  @ScopedState(\RoomScope.room) private var room

  var body: some View {
    Text(room.name)
  }
}

There’s no custom initializer anymore. The room ID is supplied at the scope boundary, and any view beneath it can resolve the selected room without passing the ID through intermediate views.

Thinking with scopes

We now have the basic shape, but not a complete implementation. One glaring omission is detecting changes in the injected scopes: each property will resolve its value again when its configuration changes, but will ignore any changes in parent scopes once connected.

The ScopedState library handles those source changes. It also supports connections that receive updates over time and write values back to their sources, among some other features. The following examples use the library rather than our implementation above.

Until now, we’ve been passing around a whole Room object. That’s a valid way of structuring code, but a scope can also expose individual values and operations.

For our home example, we could define:

import ScopedState

struct HomeScope {
  let roomIDs: Connection<[Room.ID]>
  
  let roomScope: ConfiguredConnection<RoomScope, Room.ID>
}

struct RoomScope {
  let name: Connection<String>
  
  let detectLights: Connection<() -> Void>

  let lightIDs: Connection<[Light.ID]>

  let lightScope: ConfiguredConnection<LightScope, Light.ID>
}

struct LightScope {
  let name: Connection<String>
  
  let isOn: WritableConnection<Bool>
}

RoomScope now provides a name, the IDs of its lights, a way to establish a scope for each light, and an action to discover more lights. The action is just a closure. Its implementation can capture whatever it needs to operate on the selected room, so calling it doesn’t require the view to supply an ID.

RoomView can use these connections directly.

struct RoomView: View {
  @ScopedState(\RoomScope.name) private var name

  @ScopedState(\RoomScope.detectLights) private var detectLights

  @ScopedState(\RoomScope.lightIDs) private var lightIDs

  var body: some View {
    List(lightIDs, id: \.self) { lightID in
      LightView()
        .scope(\RoomScope.lightScope, configuration: lightID)
    }
    .navigationTitle(name)
    .toolbar {
      Button("Find lights", action: detectLights)
    }
  }
}

The light view doesn’t need the room or light ID. It gets a name and an on/off value from its scope.

struct LightView: View {
  @ScopedState(\LightScope.name) private var name

  @ScopedState(\LightScope.isOn) private var isOn

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

WritableConnection is the new part here. It makes $isOn a Binding<Bool>, suitable for passing directly to a Toggle. Writing through that binding calls the connection’s setter, and changes to the source update the connected state. A plain Connection<Bool> wouldn’t provide that writable projection. This distinction is part of the scope’s declaration, so a container must supply a writable connection wherever one is required.

Note that a non-writable Connection doesn't mean that an object becomes immutable. With the earlier Connection<Room>$room.name would still provide a binding to the room’s writable name property. The view just wouldn’t be allowed to replace the connected Room instance itself.

The scope definitions don’t say where any of these values come from. For an @Observable light model, a container could connect directly to its properties.

@MainActor final class LightContainer {
  let light: Light

  init(light: Light) {
    self.light = light
  }

  var scope: LightScope {
    LightScope(
      name: .observation(light, \.name),
      isOn: .observation(light, \.isOn)
    )
  }
}

Observation is only one possible source. The library implements factories for connections to support Combine subjects and publishers, asynchronous sequences, and constant values, but there is also plain connection instantiation that can be used to support callback APIs or other update mechanisms.

A view doesn't care about which type of connection source is used, and different sources can be used in the same scope. A preview container can choose to bypass creating and connecting a Light instance altogether.

@MainActor private final class PreviewContainer {
  var scope: LightScope {
    .init(
      name: .constant("Ceiling Light"),
      isOn: .initial(false)
    )
  }
}

#Preview {
    @Previewable @State var container = PreviewContainer()

    LightView()
        .container(container, scope: \.scope)
}

Conclusion

This has fully replaced the way I structure SwiftUI code in my personal projects.

The examples here leave out quite a bit of what makes it a usable library, but they show the part I wanted to share: how to build your own state abstractions using SwiftUI’s existing tools. Even if ScopedState isn’t what you need, I hope the process of getting there gives you something useful to work with.

If you decide to try it for yourself or just have questions or feedback, feel free to contact me on Mastodon or X.