Child State not properly updated in the "parent" reducer after using NavigationLink in a ForEach #575
-
I've more or less followed this use case: https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift despite I don't need to Load, so it should be simpler. In a nutshell, I've a list of recipes
Good thing is, 1. worked perfectly with a Unfortunately, something in my project is wrong. I can well edit the Recipe, every edit in I joined a video of the application, so it will be easier to understand what's wrong and what I'm expecting. The code is available here: https://github.com/renaudjenny/WeeklyRecipePlanning/tree/main/Shared/Recipe There is the "domain" I'm suspecting that my // in RecipeListCore
struct RecipeListState: Equatable {
var recipes: IdentifiedArrayOf<RecipeState>
var selection: Identified<Recipe.ID, RecipeState>?
}
enum RecipeListAction: Equatable {
case addNewRecipe
case setNavigation(selection: Recipe.ID?)
...
case recipe(RecipeAction)
}
struct RecipeListEnvironment { ... }
let recipeListReducer = Reducer<RecipeListState, RecipeListAction, RecipeListEnvironment>.combine(
recipeReducer
.pullback(
state: \Identified.value,
action: .self,
environment: { $0 }
)
.optional()
.pullback(
state: \.selection,
action: /RecipeListAction.recipe,
environment: { RecipeEnvironment(uuid: $0.uuid) }
),
Reducer { state, action, environment in
switch action {
case .addNewRecipe:
let newRecipe = RecipeState(recipe: .new(id: environment.uuid()))
state.recipes.insert(newRecipe, at: 0)
return Effect(value: .setNavigation(selection: newRecipe.id))
case let .setNavigation(selection: .some(id)):
guard let recipe = state.recipes[id: id]
else { return .none }
state.selection = Identified(recipe, id: \.id)
return .none
case .setNavigation(selection: .none):
state.selection = nil
return .none
...
case .recipe:
// If I put a Breakpoint here, each modification on a recipe is well breaking here. However, when I `po state.recipes[...].mealCount` for instance, it's not well updated (it's still the same value, like non modified)
return Effect(value: .save)
....
}
}
) // In RecipeListView
...
struct RecipeListView: View {
let store: Store<RecipeListState, RecipeListAction>
var body: some View {
WithViewStore(store) { viewStore in
NavigationView {
List {
ForEach(viewStore.recipes) { recipe in
NavigationLink(
destination: IfLetStore(
store.scope(
state: \.selection?.value,
action: RecipeListAction.recipe
),
then: RecipeView.init(store:)
),
tag: recipe.id,
selection: viewStore.binding(
get: \.selection?.id,
send: RecipeListAction.setNavigation(selection:)
)
) {
RecipeRowView(recipe: recipe.recipe)
}
}
.onDelete { viewStore.send(.delete($0)) }
}
.navigationTitle("Recipes")
.toolbar {
ToolbarItem(placement: addRecipeButtonPlacement) {
addNewRecipeButton
}
}
}
.onAppear { viewStore.send(.load) }
}
}
} Perhaps something is wrong in // In RecipeCore
@dynamicMemberLookup
struct RecipeState: Equatable, Identifiable {
var recipe: Recipe
var ingredientList: IngredientListState {
didSet { recipe.ingredients = IdentifiedArrayOf(ingredientList.ingredients.map(\.ingredient)) }
}
var id: Recipe.ID { recipe.id }
init(recipe: Recipe) {
self.recipe = recipe
self.ingredientList = IngredientListState(
ingredients: IdentifiedArrayOf(recipe.ingredients.map { IngredientState(ingredient: $0) })
)
}
subscript<T>(dynamicMember keyPath: WritableKeyPath<Recipe, T>) -> T {
get { recipe[keyPath: keyPath] }
set { recipe[keyPath: keyPath] = newValue }
}
}
enum RecipeAction: Equatable {
case nameChanged(String)
case mealCountChanged(Int)
case ingredientList(IngredientListAction)
}
struct RecipeEnvironment {
var uuid: () -> UUID
}
let recipeReducer = Reducer<RecipeState, RecipeAction, RecipeEnvironment>.combine(
ingredientListReducer.pullback(
state: \.ingredientList,
action: /RecipeAction.ingredientList,
environment: { IngredientListEnvironment(uuid: $0.uuid) }
),
Reducer { state, action, environment in
switch action {
case let .nameChanged(name):
state.name = name
return .none
case let .mealCountChanged(mealCount):
state.mealCount = mealCount
return .none
case .ingredientList:
return .none
}
}
) // in RecipeView
struct RecipeView: View {
let store: Store<RecipeState, RecipeAction>
var body: some View {
WithViewStore(store) { viewStore in
Form {
Section(header: Text("Title")) {
TextField("Name", text: viewStore.binding(get: { $0.name }, send: RecipeAction.nameChanged))
.font(.title)
}
Stepper("Meal count: \(viewStore.mealCount)", value: viewStore.binding(get: { $0.mealCount }, send: RecipeAction.mealCountChanged), in: 0...99)
IngredientListView(store: store.scope(state: { $0.ingredientList }, action: RecipeAction.ingredientList))
}
}
}
} Sorry for this very long message, thanks a lot for the ones who read everything 😂. I hope someone could help me figure it out where the problem is. Cheers! |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 1 reply
-
I've probably found something relevant: struct RecipeListState: Equatable {
var recipes: IdentifiedArrayOf<RecipeState>
var selection: Identified<Recipe.ID, RecipeState>? {
didSet { print(selection?.value.mealCount) }
}
} This logs the correct struct RecipeListState: Equatable {
var recipes: IdentifiedArrayOf<RecipeState>
var selection: Identified<Recipe.ID, RecipeState>? {
didSet { selection.map { recipes[id: $0.id] = $0.value } }
}
} But IMO that breaks the Unidirectional Flow ( func testUpdateAndSaveRecipe() throws {
let store = try XCTUnwrap(self.store)
let saveSubject = try XCTUnwrap(self.saveSubject)
let recipes = [Recipe].test
let firstRecipe = try XCTUnwrap(recipes.first)
store.assert(
.send(.setNavigation(selection: firstRecipe.id)) {
$0.selection = Identified(RecipeState(recipe: firstRecipe), id: firstRecipe.id)
},
.send(.recipe(RecipeAction.nameChanged("Modified by Test"))) { // State change does not match expectation: …
$0.recipes[0].name = "Modified by Test"
},
.do { self.mainQueue.advance(by: .seconds(1)) },
.receive(.save),
.do { saveSubject.send(true) },
.receive(.saved(.success(true)))
)
} The test fails, and I can totally understand why. In the example here: https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift this seems to work seamlessly, I guess I'm not looking in the right place... I just updated the code with the failing test few minutes ago. |
Beta Was this translation helpful? Give feedback.
-
TL;DR: if you list something, like a list of recipes named struct RecipeListState: Equatable {
var recipes: IdentifiedArrayOf<RecipeState>
var selection: Identified<Recipe.ID, RecipeState>?
} Action enum RecipeListAction: Equatable {
case setNavigation(selection: Recipe.ID?)
case recipe(RecipeAction)
} Reducer let recipeListReducer = Reducer<RecipeListState, RecipeListAction, RecipeListEnvironment>.combine(
recipeReducer
.pullback(
state: \Identified.value,
action: .self,
environment: { $0 }
)
.optional()
.pullback(
state: \.selection,
action: /RecipeListAction.recipe,
environment: { RecipeEnvironment(uuid: $0.uuid) }
),
Reducer { state, action, environment in
switch action {
case let .setNavigation(selection: .some(id)):
state.recipes[id: id].map {
state.selection = Identified($0, id: \.id)
}
return .none
case .setNavigation(selection: .none):
state.selection = nil
return .none
case .recipe:
state.selection.map {
state.recipes[id: $0.id] = $0.value
}
return .none
}
}
) It was actually the case right in the example case study here: https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift#L65-L67 |
Beta Was this translation helpful? Give feedback.
TL;DR: if you list something, like a list of recipes named
recipes
, and you can't use use the coupleForEachStore
andrecipeReducer.forEach(...)
because you want to control the navigation withNavigationLink(destination:tag:selection:label:)
what I namedNavigationLink
withselection:
, you have to use the techniqueIdentified selection
:State
Action
Reducer