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:
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.