How to get List to not re-render cells that didn't change #3682
-
When I have a List backed by a TCA store, and the each cell in the List has its own store, which is scoped out from the List's store using Is there a way to avoid rendering list cells that didn't have any updates, like the non-TCA version? Did I miss something about TCA & the stores/reducers are not set up correctly? Here is the demo and code: over-render.mov(In this case, when there are 5 rows and tapping the "Add row" button should only render cell 6, not 1-5) TCA version: import ComposableArchitecture
import SwiftUI
// MARK: - List
@Reducer
struct ListTCA {
@ObservableState
struct State: Equatable {
var cells: IdentifiedArrayOf<CellTCA.State> = []
}
enum Action {
case addTapped
case cellAction(IdentifiedActionOf<CellTCA>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addTapped:
let newId = state.cells.count
state.cells.append(.init(id: newId))
return .none
case .cellAction:
return .none
}
}
.forEach(\.cells, action: \.cellAction) {
CellTCA()
}
}
}
struct ContentViewTCA: View {
let store = Store(initialState: .init()) {
ListTCA()
}
var body: some View {
List {
Button("Add row") {
store.send(.addTapped)
}
ForEach(store.scope(state: \.cells, action: \.cellAction)) { cellStore in
CellView(store: cellStore)
}
}
.listStyle(.plain)
}
}
// MARK: - Cell
@Reducer
struct CellTCA {
@ObservableState
struct State: Equatable, Identifiable {
let id: Int
var text: String? = nil
var cellId: String {
id.description
}
}
enum Action {
case onAppear
case contentFetched(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
// asynchronously load content
return .run { [id = state.id] send in
await send(.contentFetched("Content of \(id) fetched at \(Date())"))
}
case .contentFetched(let content):
state.text = content
return .none
}
}
}
}
struct CellView: View {
let store: StoreOf<CellTCA>
var body: some View {
let _ = print("rendering cell \(store.cellId)")
VStack {
Text(store.text ?? "")
.onAppear { store.send(.onAppear) }
}
}
}
#Preview {
ContentViewTCA()
} Non-TCA version import SwiftUI
// MARK: - List
@MainActor class ListStoreNonTCA: ObservableObject {
struct State {
var cellStores: [CellStoreNonTCA] = []
}
@Published var state: State
init() {
self.state = State()
}
func addTapped() {
let newId = state.cellStores.count
state.cellStores.append(.init(state: .init(id: newId)))
}
}
struct ContentViewNonTCA: View {
@ObservedObject var store = ListStoreNonTCA()
var body: some View {
List {
Button("Add row") {
store.addTapped()
}
ForEach(store.state.cellStores, id: \.state.id) { cellStore in
CellViewNonTCA(store: cellStore)
}
}
.listStyle(.plain)
}
}
// MARK: - Cell
@MainActor final class CellStoreNonTCA: ObservableObject {
struct State {
let id: Int
var text: String? = nil
}
@Published var state: State
init(state: State) {
self.state = state
}
func onAppear() {
Task {
state.text = "Content fetched at \(Date())"
}
}
}
struct CellViewNonTCA: View {
@ObservedObject var store: CellStoreNonTCA
var body: some View {
let _ = print("rendering cell \(store.state.id)")
VStack {
Text(store.state.text ?? "")
.onAppear { store.onAppear() }
}
}
}
#Preview {
ContentViewNonTCA()
} Thanks a lot! |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 1 reply
-
I figured out a way to avoid the re-rendering issue, basically if I just pass the id to each cell and let each cell view create their own store and keep it as a But this kinda feels a little anti-pattern, and we lost the ability to communicate between parent and child stores: Screen.Recording.2025-05-19.at.11.30.38.AM.movimport ComposableArchitecture
import SwiftUI
// MARK: - List
@Reducer
struct ListTCA {
@ObservableState
struct State: Equatable {
var cells: IdentifiedArrayOf<CellTCA.State> = []
}
enum Action {
case addTapped
case cellAction(IdentifiedActionOf<CellTCA>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addTapped:
let newId = state.cells.count
state.cells.append(.init(id: newId))
return .none
case .cellAction:
return .none
}
}
// .forEach(\.cells, action: \.cellAction) {
// CellTCA()
// }
}
}
struct ContentViewTCA: View {
let store = Store(initialState: .init()) {
ListTCA()
}
var body: some View {
List {
Button("Add row") {
store.send(.addTapped)
}
// ForEach(store.scope(state: \.cells, action: \.cellAction)) { cellStore in
ForEach(store.cells) { cell in
CellView(id: cell.id)
}
}
.listStyle(.plain)
}
}
// MARK: - Cell
@Reducer
struct CellTCA {
@ObservableState
struct State: Equatable, Identifiable {
let id: Int
var text: String? = nil
var cellId: String {
id.description
}
}
enum Action {
case onAppear
case contentFetched(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
// asynchronously load content
return .run { [id = state.id] send in
await send(.contentFetched("Content of \(id) fetched at \(Date())"))
}
case .contentFetched(let content):
state.text = content
return .none
}
}
}
}
struct CellView: View {
@StateObject var store: StoreOf<CellTCA>
init(id: Int) {
// this way fixes the re-render issue:
_store = .init(wrappedValue: Store(initialState: .init(id: id), reducer: {
CellTCA()
}))
}
var body: some View {
let _ = print("rendering cell \(store.cellId)")
VStack {
Text(store.text ?? "")
.onAppear { store.send(.onAppear) }
}
}
}
#Preview {
ContentViewTCA()
} I also tried using scoping to pass the store from the List to the Cell but storing the store as |
Beta Was this translation helpful? Give feedback.
Hey @ba01ei , I believe your question relates to another one that was asked recently. You might find this thread helpful:
#2973