Transitioning to @Observable in SwiftUI

A guide on how to adopt the new Observation framework for SwiftUI apps


The Observation Framework

One of the biggest changes to Swift and SwiftUI in iOS 17 was the introduction of the Observable macro. This macro lets an object update any observers whenever its properties change. To add observation support to a type, all you have to is attach the @Observable macro:

@Observable
class Post {
    var title: String
    var description: String
    var publishedAt: Date

    init(title: String, description: String, publishedAt: Date) {
        // ...
    }
}

With SwiftUI, where UI = f(State), the Observation framework fits in perfectly, as Views can redraw efficiently based on state changes in an Observable-conforming model. Let’s take a look at how SwiftUI is able to incorporate Observable in iOS 17.

@Observable vs ObservableObject

The easiest way to get started transitioning to the Observation framework is to convert your ObservableObject conformances into @Observable. Let’s start with an ObservableObject view model to hold a list of posts:

class PostsViewModel: ObservableObject {
    @Published var posts = [Post]()
}

To convert this view model into an @Observable object, all you need to do is the following:

@Observable
class PostsViewModel {
    var posts = [Post]()
}

And ta-da! Now this view model’s posts property will automatically share any changes with its observers. In this case, that means our SwiftUI views.

Ignoring properties

If there’s a property that you don’t want to track, you can use the ObservationIgnored macro inside of the Observable object.

@Observable
class PostsViewModel {
    var posts = [Post]()

    @ObservationIgnored
    private var dataSource = PostsDataSource()
}

Any changes made to the dataSource property will now no longer update observers.

@State vs @StateObject

To use an @Observable object in a SwiftUI View, we simply need to change it from a @StateObject to @State. Using our updated PostsViewModel, we can see this here:

struct PostsView: View {
    // We use @State instead of @StateObject now
    @State private var viewModel = PostsViewModel()

    var body: some View {
        ScrollView {
            LazyVStack {
                // This will update anytime posts changes!
                ForEach(viewModel.posts) { post in
                    // ...
                }
            }
        }
    }
}

@Bindable vs @ObservedObject

If we want to pass down our view model to a child view from this parent view, we can remove the @ObservedObject property wrapper because SwiftUI automatically tracks any @Observable properties that body reads directly.

struct PostsChildView: View {
    // We can remove @ObservedObject
    var viewModel: PostsViewModel

    var body: some View {
        Text(String(viewModel.posts.count))
    }
}

However, if a view needs a binding from the @Observable type, we can replace @ObservedObject with @Bindable. In this case, we may have something like this:

struct PostsChildView: View {
    // Replace @ObservedObject with @Bindable
    @Bindable var viewModel: PostsViewModel

    var body: some View {
        SomeBindableView(posts: $viewModel.posts)
    }
}

@Environment vs @EnvironmentObject

Lastly, we can look at one of the best DX changes in SwiftUI with Observation.

Instead of passing down a @StateObject with the .environmentObject(_:) modifier, we can now pass down a @State observable with the .environment(_:) modifier.

In order to read from the environment, however, we must specify the observable’s type.

struct PostsView: View {
    @State private var viewModel = PostsViewModel()

    var body: some View {
        ScrollView {
            // ...
        }
        .environment(viewModel)
    }
}

struct PostsChildView: View {
    // Remember to specify the type!
    @Environment(PostsViewModel.self) private var viewModel

    var body: some View {
        // ...
    }
}

Incremental Changes

If you don’t feel like adopting all of these changes at once, SwiftUI allows you to adopt them incrementally. @StateObject and @EnvironmentObject support types that use the Observable macro, which means you can start by removing ObservedObject and then update your views later.

A Cheat Sheet

OldNewWhen?Where?
@StateObject@StateAnytimeView
@ObservedObjectNo wrapperWhen body only reads the valueView
@ObservedObject@BindableWhen a body view needs a bindingView
@EnvironmentObject@EnvironmentAnytimeView
ObservableObject@ObservableAnytimeModel

That’s the Tea

Thanks for reading! Hopefully you learned how to use the new Observation framework in your future SwiftUI projects. If you’d like to learn more, you can check out Apple’s documentation.