Skip to content

Conversation

@lanserxt
Copy link
Contributor

When you need to create StateObject from wrapper which relies on DI values (either in constructor or in method), it should avoid objects creation out of auto-closure.

This was highlighted by Vincent Pradeilles: https://www.linkedin.com/in/vincentpradeilles/overlay/about-this-profile/

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds guidance on the correct pattern for initializing @StateObject with parameters in a view's custom initializer. It addresses a subtle performance issue where creating an object instance outside the autoclosure parameter can lead to unnecessary object allocations during SwiftUI's view initialization process.

Changes:

  • Adds new subsection "@StateObject instantiation in View's initializer" to state-management.md
  • Provides wrong vs. correct examples showing how to pass initialization parameters to @StateObject
  • Includes attribution to Vincent Pradeilles for highlighting this pattern

Copy link
Owner

@AvdLee AvdLee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one, I learned something new and alongside, our agents will do now, too! Thanks for your contribution 💪🏻

@AvdLee AvdLee merged commit f5391dd into AvdLee:main Jan 25, 2026
1 check passed
@lanserxt
Copy link
Contributor Author

Same for me ) Team work - makes the dream work!

@malhal
Copy link

malhal commented Feb 2, 2026

The issue here is that initializing @StateObject with a parameter fundamentally breaks its design as a Source of Truth. Ideally in the future this will be a compiler error to enforce better habits.

@lanserxt
Copy link
Contributor Author

lanserxt commented Feb 2, 2026

The issue here is that initializing @StateObject with a parameter fundamentally breaks its design as a Source of Truth. Ideally in the future this will be a compiler error to enforce better habits.

Yes, but often that StateObjects needs an external data.

@malhal
Copy link

malhal commented Feb 2, 2026

The issue here is that initializing @StateObject with a parameter fundamentally breaks its design as a Source of Truth. Ideally in the future this will be a compiler error to enforce better habits.

Yes, but often that StateObjects needs an external data.

No, never. If you explain your use case we can show you how to use bindings, combine pipelines or observable to solve your problem. Usually this is done by those that haven't learning bindings.

@lanserxt
Copy link
Contributor Author

lanserxt commented Feb 2, 2026

The issue here is that initializing @StateObject with a parameter fundamentally breaks its design as a Source of Truth. Ideally in the future this will be a compiler error to enforce better habits.

Yes, but often that StateObjects needs an external data.

No, never. If you explain your use case we can show you how to use bindings, combine pipelines or observable to solve your problem. Usually this is done by those that haven't learning bindings.

If we omit the Architectural question: this is might be a possible use case

// MARK: - Domain model (shared, reference type)

final class Movie: ObservableObject, Identifiable {
    let id: UUID
    @Published var title: String
    @Published var isFavorite: Bool

    init(id: UUID = UUID(), title: String, isFavorite: Bool) {
        self.id = id
        self.title = title
        self.isFavorite = isFavorite
    }
}

final class MovieEditSession: ObservableObject {
    @Published var title: String
    @Published var isFavorite: Bool

    private let movie: Movie

    init(movie: Movie) {
        self.movie = movie
        self.title = movie.title
        self.isFavorite = movie.isFavorite
    }

    func commit() {
        movie.title = title
        movie.isFavorite = isFavorite
    }

    func discard() {
        title = movie.title
        isFavorite = movie.isFavorite
    }
}

// MARK: - View

struct MovieDetailsView: View {
    @ObservedObject var movie: Movie
    @StateObject private var session: MovieEditSession

    init(movie: Movie) {
        self.movie = movie
        _session = StateObject(wrappedValue: MovieEditSession(movie: movie))
    }

    var body: some View {
        Form {
            TextField("Title", text: $session.title)
            Toggle("Favorite", isOn: $session.isFavorite)

            HStack {
                Button("Discard") {
                    session.discard()
                }
                Spacer()
                Button("Save") {
                    session.commit()
                }
            }
        }
    }
}

@malhal
Copy link

malhal commented Feb 3, 2026

Thanks for sharing, at first glance I see a few issues but I’ll follow up tomorrow with a full solution.

Movie class can’t have id UUID for Identifiable, class vis idenity by its pointer. But yhis one can be a struct since doesn’t have any relations.
MovieEditSession can also be struct since doesn’t do anything asynchronous.
Don’t see any @binding use.

@malhal
Copy link

malhal commented Feb 3, 2026

This refactor moves the logic away from the "StateObject init trap" and embraces the Value Type nature of SwiftUI.

By changing the session to a struct MovieEditorConfig, we treat the draft state as a piece of data that can be easily initialized and mutated. Using @Binding inside the View allows us to pass these values around without worrying about the lifecycle mismatches of a reference-type session object.

Something like this:

import SwiftUI

// MARK: - Domain Model 
final class Movie: ObservableObject, Identifiable {
    @Published var title: String
    @Published var isFavorite: Bool
    
    var id: ObjectIdentifier { .init(self) }
    
    init(title: String, isFavorite: Bool) {
        self.title = title
        self.isFavorite = isFavorite
    }
}

// MARK: - Editor Config 
struct FavoriteEditorConfig {
    var isEditing: Bool = false
    var title: String = ""
    var isFavorite: Bool = false
    
    // A simple callback with no parameters
    var onSave: (() -> Void)?
    
    mutating func present(title: String, isFavorite: Bool, onSave: @escaping () -> Void) {
        self.title = title
        self.isFavorite = isFavorite
        self.onSave = onSave
        self.isEditing = true
    }
    
    func commit() {
        onSave?()
    }
    
    mutating func dismiss() {
        isEditing = false
    }
}

// MARK: - Parent View
struct MovieListView: View {
    @StateObject private var movie = Movie(title: "Inception", isFavorite: false)
    @State private var config = FavoriteEditorConfig()
    
    var body: some View {
        List {
            Text(movie.title).font(.largeTitle)
            
            Button("Edit Movie") {
                config.present(title: movie.title, isFavorite: movie.isFavorite) {
                    movie.title = config.title
                    movie.isFavorite = config.isFavorite
                    config.dismiss()
                }
            }
        }
        .sheet(isPresented: $config.isEditing) {
            NavigationStack {
                FavoriteEditor(config: $config)
            }
        }
    }
}

// MARK: - Detail View
struct FavoriteEditor: View {
    @Binding var config: FavoriteEditorConfig
    
    var body: some View {
        Form {
            TextField("Title", text: $config.title)
            Toggle("Favorite", isOn: $config.isFavorite)
        }
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Done") { config.commit() }
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel") { config.dismiss() }
            }
        }
    }
}

@lanserxt
Copy link
Contributor Author

lanserxt commented Feb 3, 2026

@malhal Thanks for make a snippet and explanation.

FavoriteEditorConfig is acting like a bridge between states. That will remove wrapper. But situations like in PR are exists and not always we can decouple it. Yes, it's an architectural question. What if there will be a more complex logic in MovieEditSession? Add other configs?

@malhal
Copy link

malhal commented Feb 3, 2026

Actually there shouldn't be a single situation the PR is required to break the state as a source of truth design.

The Config State can be as complex as the business requirements demand. You can include validation, throw errors, or even control UI flow (like returning a Bool to a closure to prevent a sheet from dismissing) all of which remain fully testable in isolation.

Beyond just handling Bindings, the key principle here is that SwiftUI requires shared state to live in the highest common ancestor and be passed down (as a let for read-only or a Binding for read-write, via computed vars for more complex data flows). By leveraging this and using a proper configuration pattern, we can satisfy complex logic requirements while completely avoiding the StateObject(wrappedValue:) anti-pattern and the architectural debt it creates.

@lanserxt
Copy link
Contributor Author

lanserxt commented Feb 7, 2026

Actually there shouldn't be a single situation the PR is required to break the state as a source of truth design.

The Config State can be as complex as the business requirements demand. You can include validation, throw errors, or even control UI flow (like returning a Bool to a closure to prevent a sheet from dismissing) all of which remain fully testable in isolation.

Beyond just handling Bindings, the key principle here is that SwiftUI requires shared state to live in the highest common ancestor and be passed down (as a let for read-only or a Binding for read-write, via computed vars for more complex data flows). By leveraging this and using a proper configuration pattern, we can satisfy complex logic requirements while completely avoiding the StateObject(wrappedValue:) anti-pattern and the architectural debt it creates.

@malhal Agree that it's probably smelly-code, but let's just assume that StateObject has an init with wrappedValue and we are avoiding re-instantiation beyond @autoclosure. Out of DI scope. Since we are not telling LLM to use this init. Just check the init structure.

@EngOmarElsayed
Copy link
Collaborator

EngOmarElsayed commented Feb 7, 2026

Actually I think there is no problem init the @StateObject in the init of the view as long as you know that it will not update when the movie change, so this means that the view it self is the parent view which mean that movie will not change and instead it is like needing info from the previous screen.

@malhal
Copy link

malhal commented Feb 7, 2026

You've highlighted a common trap: Views aren't screens. Even if a View feels like a new screen, the SwiftUI hierarchy doesn't guarantee it won't be re-initialized. By relying on the "as long as you know it won't change" rule, you're fighting the framework's lifecycle.

If you lift that @StateObject to the parent View and pass the data down, the child no longer needs a custom init. You remove the fragility entirely and let SwiftUI handle the lifecycle the way it was designed.

@lanserxt
Copy link
Contributor Author

lanserxt commented Feb 8, 2026

You've highlighted a common trap: Views aren't screens. Even if a View feels like a new screen, the SwiftUI hierarchy doesn't guarantee it won't be re-initialized. By relying on the "as long as you know it won't change" rule, you're fighting the framework's lifecycle.

If you lift that @StateObject to the parent View and pass the data down, the child no longer needs a custom init. You remove the fragility entirely and let SwiftUI handle the lifecycle the way it was designed.

Suggesting to add this info in new PR. Will mention you there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants