Modal State using Shared State

Imagine a simple scenario: HomeView displaying a list of items with a modal AddItemView for adding new items to that list.

Both views should have access to the same shared state:

struct ItemsState {
    var items: IdentifiedArrayOf<Item>
}

Question: what is the best way to share ItemsState with AddItemView while ensuring the changes made to the items propagate to the shared state?


I see two options.

Option 1: define an optional AddItemState in HomeState which gets initialized with the shared ItemsState:

struct HomeState {
    var itemsState: ItemsState
    var addItemState: AddItemState?
}

struct AddItemState {
    var itemsState: ItemsState
    ...
}

The downside with his approach is that changes made to the itemsState within AddItemState will not propagate back to the itemsState in the HomeState. To achieve that, one has to manually override the itemsState in the homeReducer when the AddItemView is dismissed, for example:

let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> { state, action, environment in
    switch action {
    case .setAddItemViewPresented(false):
    	state.itemsState = addItemState?.itemsState ?? state.itemsState
    	state.addItemState = nil
    	return .none
    ...
    } 
}

This approach can lead to synchronization bugs, in cases when HomeState makes changes to the itemsState while the AddItemView is presented. Both views end up with a different source of truth, and moreover, the changes made to items in HomeView get overridden and lost when the AddItemView gets dismissed.


Option 2: define a computed AddItemContainerState:

struct HomeState {
	var itemsState: ItemsState

	var addItemState: AddItemState
	var addItemContainerState: AddItemContainerState {
	    get {
	        .init(itemsState: itemsState, addItemState: addItemState)
	    }
	    set {
	        self.itemsState = newValue.itemsState
	        self.addItemState = newValue.addItemState
	    }
	}
}

AddItemView would now use AddItemContainerState instead. Thanks to the computed nature of this approach, it solves the synchronization issues we saw in option 1, but it does come with a downside.

addItemState has to be defined as a non-optional now, because we need it in AddItemView. This is rather inconvenient, because we always want a fresh instance of AddItemState when the AddItemView gets presented. It forces us to make manual invalidations of the addItemState, right before the AddItemView is presented, for example:

let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> { state, action, environment in
    switch action {
    case .setAddItemViewPresented(true):
    	state.addItemState = AddItemState()
    	state.isShowingAddItemView = true
    	return .none
    ...
    } 
}

While this approach is more robust when it comes to data flow, it's also inconvenient to use and less clear in my opinion.


I would love to hear your thoughts, improvement suggestions, and other options I haven't thought about. Thanks!

Options 2 is described in the 01-GettingStarted-SharedState case study