Shared state best practices? #879
Replies: 3 comments 19 replies
-
One way to do it would be to make struct ParentState: Equatable {
var items: [String]
@BindableState var query: String = ""
init(items: [String]) {
self.items = items
}
var child: ChildState {
get { .init(items: items) }
set { items = newValue.items }
}
} If your child feature have some internal state you don't want to handle with your parent, you can use a private storage property: struct ParentState: Equatable {
var items: [String]
@BindableState var query: String = ""
init(items: [String]) {
self.items = items
}
private var _child: ChildState = .init(items: [])
var child: ChildState {
get {
var state = _child
state.items = items
return state
}
set {
_child = newValue
items = newValue.items
}
}
} This allows to preserve any internal state set by the child, while ensuring it always receives an up to date You can furthermore append additional behaviors when reducing child actions in |
Beta Was this translation helpful? Give feedback.
-
I've done some more work on this and come up with a possible solution, although I'm not sure quite whether there might be some hidden gotchas. I've not seen another example of passing more than one store to a view, but this seemed to open up some possibilities. There were several parts to the solution:
Item.swiftimport Foundation
struct Item: Identifiable, Equatable {
var id = UUID()
var title: String
} ItemArray.swiftimport Foundation
import ComposableArchitecture
struct ItemArray {
private init() {}
// MARK: S T A T E
typealias State = IdentifiedArrayOf<Item>
// MARK: A C T I O N
enum Action: Equatable {
case append(Item)
case update(Item)
case move(ids: [Item.ID], toId: Item.ID?)
case remove(ids: [Item.ID])
}
// MARK: R E D U C E R
static let reducer = Reducer<State, Action, Void> { state, action, _ in
switch action {
case .append(let item):
state.append(item)
case .update(let item):
guard let index = state.firstIndex(where: { $0.id == item.id }) else { break }
state.update(item, at: index)
case .move(ids: let ids, toId: let toId):
// convert Item.ID into [Int] offsets
let source = ids
// convert Id to offset?
.map({ id in state.firstIndex(where: { $0.id == id })})
// remove nil
.filter({ $0 != nil })
// unwrap
.map({ $0! })
// dest is either valid offset or out of range
let dest = state.firstIndex(where: { $0.id == toId })
// move source offsets to dest offset (or end of list if nil)
state.move(fromOffsets: IndexSet(source), toOffset: dest ?? state.count)
case .remove(ids: let ids):
// convert Item.ID to [Int] offsets
let source = ids.map({ id in state.firstIndex(where: { $0.id == id })}).filter({ $0 != nil }).map({ $0! })
state.remove(atOffsets: IndexSet(source))
}
return .none
}
// MARK: S T O R E
typealias Store = ComposableArchitecture.Store<State, Action>
} Parent.swiftimport SwiftUI
import ComposableArchitecture
struct Parent {
// MARK: S T A T E
struct State: Equatable {
var items: ItemArray.State
var child: Child.State
init(items: [Item]) {
self.items = ItemArray.State(uniqueElements: items)
self.child = Child.State()
}
}
// MARK: T E S T D A T A
static var testData = [
Item(title: "Item 1"),
Item(title: "Item 2"),
Item(title: "Item 3"),
Item(title: "Item 4"),
]
// MARK: A C T I O N
enum Action: Equatable, BindableAction {
case binding(BindingAction<State>)
case child(action: Child.Action)
case items(action: ItemArray.Action)
}
// MARK: R E D U C E R
static let reducer = Reducer<State, Action, Void>.combine(
ItemArray.reducer.pullback(
state: \.items,
action: /Action.items,
environment: { _ in Void() }
),
Child.reducer.pullback(
state: \.child,
action: /Action.child,
environment: { _ in Void() }
),
Reducer { state, action, _ in
return .none
}
.binding()
)
// MARK: S T O R E
typealias Store = ComposableArchitecture.Store<State, Action>
// MARK: V I E W
struct View: SwiftUI.View {
var store: Store
var body: some SwiftUI.View {
WithViewStore(self.store) { viewStore in
NavigationView {
Child.View(
childStore: self.store.scope(state: { $0.child }, action: Parent.Action.child(action:)),
itemStore: self.store.scope(state: { $0.items }, action: Parent.Action.items(action:))
)
.toolbar {
ToolbarItem(placement: .primaryAction, content: { EditButton() })
ToolbarItem(placement: .bottomBar) {
Button("Add Item") { viewStore.send(.items(action:.append(Item(title: "Item \(viewStore.items.count+1)"))))}
}
}
.navigationTitle("Searchable List")
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(.stack)
.searchable(text: viewStore.binding(\.child.$query))
}
}
}
}
// MARK: P R E V I E W
struct Parent_Previews: PreviewProvider {
static var previews: some View {
Parent.View(
store: Parent.Store(
initialState: Parent.State(
items: Parent.testData
),
reducer: Parent.reducer,
environment: Void()
)
)
}
} Child.swiftimport SwiftUI
import ComposableArchitecture
struct Child {
private init() {}
// MARK: S T A T E
struct State: Equatable {
@BindableState var query: String = ""
}
// MARK: A C T I O N
enum Action: Equatable, BindableAction {
case binding(BindingAction<State>)
}
// MARK: R E D U C E R
static let reducer = Reducer<State, Action, Void> { state, action, _ in
return .none
}
.binding()
// MARK: S T O R E
typealias Store = ComposableArchitecture.Store<State, Action>
// MARK: V I E W
struct View: SwiftUI.View {
var childStore: Store
var itemStore: ItemArray.Store
var body: some SwiftUI.View {
WithViewStore(self.childStore) { childViewStore in
WithViewStore(self.itemStore) { itemViewStore in
let filtered = itemViewStore.state.filtered(query: childViewStore.query)
if filtered.count > 0 {
List {
ForEach(filtered) { item in
HStack {
Text(item.title)
Spacer()
Button("Update") {
itemViewStore.send(.update(Item(id: item.id, title: item.title + ".")))
}
}
}
.onMove { itemViewStore.onMove(items: filtered, fromOffsets: $0, toOffset: $1) }
.onDelete { itemViewStore.onDelete(items: filtered, atOffsets: $0) }
}
} else {
Text("No matches")
}
}
}
}
}
}
// MARK: E X T E N S I O N
extension ItemArray.State where Element == Item {
func filtered(query: String) -> IdentifiedArrayOf<Element> {
self.filter({
query.isEmpty || $0.title.lowercased().contains(query.lowercased())
})
}
func mapIds(from indexset: IndexSet) -> [Element.ID] {
indexset.map({ self[$0].id })
}
subscript(safe index: Int) -> Element.ID? {
self.indices.contains(index) ? self[index].id : nil
}
}
extension ViewStore where State == ItemArray.State, Action == ItemArray.Action {
func onMove(items: ItemArray.State, fromOffsets: IndexSet, toOffset: Int) -> Void {
self.send(
.move(
ids: items.mapIds(from: fromOffsets),
toId: items[safe: toOffset]
)
)
}
func onDelete(items: ItemArray.State, atOffsets: IndexSet) -> Void {
self.send(
.remove(
ids: items.mapIds(from: atOffsets)
)
)
}
}
struct Child_Previews: PreviewProvider {
static var previews: some View {
Child.View(
childStore: Child.Store(
initialState: Child.State(),
reducer: Child.reducer,
environment: Void()
),
itemStore: ItemArray.Store(
initialState: ItemArray.State(uniqueElements: Parent.testData),
reducer: ItemArray.reducer,
environment: Void()
)
)
}
} |
Beta Was this translation helpful? Give feedback.
-
I'm still quite new to Composable Architecture, so please bear with me. Why isn't this a simple case of the parent reducer using the items array in the child property of state to translate the index to the full array and delete/move in the parent items accordingly? This seems like a pretty basic use case and I'm surprised the answer would be to use 2 stores? |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
I'm exploring TCA and trying some simple applications to better understand the architecture and see how it maps to some of problems I've encountered with SwiftUI development.
I got a simple demo which implements a Parent and Child view, with corresponding TCA State, Actions and reducers.
In the demo source,
The problem I have is that I can't figure out how to propogate changes to the filtered list back to the Parent. I'm aware I can add a case to the Parent reducer to observe the move/delete actions occuring on the Child, but the offsets that are provided are relative to the Child's filtered version of the list, which has already been modified due to combine order (it's not clear if I can reverse the reducer order in combine without other unwanted side effects).
I've played around with a separate object type to hold the items and propogate changes but this seems undesirable as it starts to allow for state changes outside of TCA. It seems like there should be a TCA state associated with a collection of items, the 'source of truth', and a subset is passed to the Child but is linked, so that changes also occur in the Parent list.
Logically the Child view should be responsible for its own actions, i.e. operations on its own list of items should be propagated back to the parent, without the parent having to repeat the changes, or perform them on behalf of the Child so that updates trickle back to the Child. The Child view -should- both function as a standalone view, and be reusable.
I can't really fathom from the SharedState and Search examples how those techniques would apply.
Here is some boilerplate for the application, which works up to the point of needing to propagate the changes between Parent and Child.
Thanks in advance for any pointers.
Parent.swift - Parent view, state, actions and reducer
Child.swift - Child view, state, actions and reducer
Beta Was this translation helpful? Give feedback.
All reactions