[Pitch] [SwiftUI] Stabilize State in Lazy Containers

Introduction

In SwiftUI, @State and @StateObject are designed to persist values across view updates, provided a view's identity remains unchanged. However, when using lazy containers such as LazyVStack or List, state can be unexpectedly lost when views scroll offscreen — even though their identities remain the same.

This proposal aims to make SwiftUI’s state management behavior more predictable in lazy containers. By providing a simple, opt-in mechanism to disable view recycling, developers gain more control over when and how state is preserved — enabling better modularity and reducing workarounds in complex view hierarchies.

Motivation

When views inside lazy container scroll offscreen, they may be discarded and recreated to reduce memory usage. While this is an intentional optimization, it can lead to surprising behavior: view-local state (e.g. @State, @StateObject) is lost even though the view’s identity hasn’t changed.

This behavior contrasts with the expectations set in WWDC21’s Demystify SwiftUI and WWDC20’s Data Essentials in SwiftUI, which indicate that SwiftUI reuses state storage based on a view’s identity. In practice, however, the current behavior makes it difficult to rely on view-local state within lazy containers.

Here’s a simplified example demonstrating the issue:

https://clive819.github.io/assets/2025/05/swiftui-state-is-not-as-reliable-as-you-think/issue.gif

struct Demo: View {

    @Namespace private var topID
    @Namespace private var bottomID

    var body: some View {
        NavigationStack {
            ScrollViewReader { proxy in
                ScrollView {
                    Color.clear
                        .frame(width: 0, height: 0)
                        .id(topID)

                    LazyVStack {
                        ForEach(0..<10_000) {
                            MyView(index: $0)
                        }
                    }

                    Color.clear
                        .frame(width: 0, height: 0)
                        .id(bottomID)
                }
                .contentMargins(.horizontal, 16, for: .scrollContent)
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) {
                        Button("scroll to bottom") {
                            withAnimation {
                                proxy.scrollTo(bottomID)
                            }
                        }
                    }
                    ToolbarItem(placement: .topBarLeading) {
                        Button("scroll to top") {
                            withAnimation {
                                proxy.scrollTo(topID)
                            }
                        }
                    }
                }
            }
        }
    }
}

fileprivate struct MyView: View {

    let index: Int

    @State private var toggle = false

    var body: some View {
        Toggle("\(index)", isOn: $toggle)
            .disabled(toggle)
    }
}

As demonstrated in the video above, views near the bottom of the scrollable content lose their state as soon as they go offscreen. In contrast, views near the top tend to preserve their state longer — but even they will reset after enough scrolling.

This presents challenges when building reusable components that are expected to manage their own internal state. While lifting state to a parent view is a viable workaround, it's not always practical — especially when the component is deeply nested or used in multiple contexts. Being forced to externalize all state can reduce encapsulation and lead to more complex, less maintainable code.

Proposed Solution

Introduce a configurable parameter to lazy containers (using LazyVStack here as an example) to allow developers to opt out of view recycling when necessary:

/// ...
/// - Parameter recycleViews: Determines whether views should be recycled to improve performance.
///   Defaults to `true`. When set to `true`, views will lose their state when recycled.
LazyVStack.init(..., recycleViews: Bool = true)

This change would not affect existing behavior unless explicitly enabled. Developers who require strict state preservation can trade off performance for correctness by passing recycleViews: false.

Tradeoffs and Considerations

  • Performance: Retaining offscreen views may increase memory usage, particularly with large data sets. However, this is an opt-in feature, allowing developers to make an informed decision based on their use case.
  • Consistency: This proposal brings SwiftUI’s behavior more in line with the expectations set by the documentation and WWDC talks.
  • Flexibility: Developers can continue using the current recycling behavior by default, while those building reusable components that manage internal state can opt into more predictable behavior.
1 Like

Unfortunately SwiftUI does not have a public evolution process, and SwiftUI itself is closed source, so there's no way to implement such a proposal anyway.

3 Likes

I wonder what a state is attached to? Virtual view (the struct describing that view) or the underlying UIKit component? I always think the the underlying UIKit component (e.g. what they are, whether they are recycled, etc) is just an implementation detail. Could the behavior be a bug?

Need to rewatch (been a while when I've seen them last time), but think purpose of lazy stacks is to re-render views and correct way is to use @Binding in that case, like:

@Observable
@MainActor
final class DemoViewModel {
    
    struct Toggle: Identifiable {
        let id: Int
        var isEnabled: Bool
    }
    
    var toggles: [Toggle]
    
    init() {
        self.toggles = Array(0..<10_000)
            .map { Toggle(id: $0, isEnabled: false) }
    }
}

struct Demo: View {
    
    @Namespace private var topID
    @Namespace private var bottomID
    @Bindable var viewModel: DemoViewModel
    
    var body: some View {
        NavigationStack {
            ScrollViewReader { proxy in
                ScrollView {
                    Color.clear
                        .frame(width: 0, height: 0)
                        .id(topID)
                    
                    LazyVStack {
                        ForEach(viewModel.toggles) {
                            MyView(
                                toggle: $viewModel.toggles[$0.id].isEnabled,
                                index: $0.id
                            )
                        }
                    }
                    
                    Color.clear
                        .frame(width: 0, height: 0)
                        .id(bottomID)
                }
                .contentMargins(.horizontal, 16, for: .scrollContent)
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) {
                        Button("scroll to bottom") {
                            withAnimation {
                                proxy.scrollTo(bottomID)
                            }
                        }
                    }
                    ToolbarItem(placement: .topBarLeading) {
                        Button("scroll to top") {
                            withAnimation {
                                proxy.scrollTo(topID)
                            }
                        }
                    }
                }
            }
        }
    }
}

fileprivate struct MyView: View {
    
    @Binding var toggle: Bool
    
    let index: Int
    
    var body: some View {
        Toggle("\(index)", isOn: $toggle)
            .disabled(toggle)
    }
}

No, that’s simply incorrect usage of the framework. Views aren’t designed to act as a storage of a state. They can be recreated as many time as needed, so anything that needs to be persisted should live somewhere else and passed in.

In UIKit’s table or collection such store of a state inside a cell would’ve been incorrect as well since toggle state would have been displayed on reused cells as well, leading to a bug. In either case, state should be passed in from some source of truth.

We meant the same thing. It's just a terminology difference and I don't think your description is accurate. A view has an identity and life cycle. What's recreated in your description is certainly not a view but just its representation :grinning_face:

@Jon_Shier is correct, this is off-topic for these forums. You should post on the Apple developer forums or file a bug with Feedback Assistant.