How to properly share state between scoped optionals and parent state?

Hi!

We use SCA in our app and have been using it since the videos started to come out, it's been mostly successful however we've run into a problem.

We have a global state and an arbitrary amount of local states which all can update the global state independently. As in the example shown here, dark mode can and will be set from a scoped state (see below), but it can also be updated from another reducer (full example in attached project).

Now, how are we supposed to synchronize the changes between all scoped states and the global state?

struct FirstState : Equatable {
  var darkMode: Bool
  var detail: DetailState?
}

enum FirstAction : Equatable {
  case navigate(show: Bool)
  case detail(DetailAction)
}

let firstReducer = Reducer<FirstState, FirstAction, AppEnvironment>.combine(
  Reducer<FirstState, FirstAction, AppEnvironment> { value, action, _ in
    switch action {
      case .navigate(true):
        value.detail = DetailState(darkMode: value.darkMode)
        return .none
      
      case .navigate(false):
        if let detail = value.detail {
          value.darkMode = detail.darkMode
        }
        value.detail = nil
        return .none
      
      case .detail:
        return .none
    }
  },
  detailReducer.optional.pullback(state: \.detail, action: /FirstAction.detail, environment: { $0 })
)

This is how we navigate and scopes the store to a new view controller

store.scope(state: { $0.detail }, action: { .detail($0) })
.ifLet(
    then: { [navigationController] store in
        let detailViewController = DetailViewController(viewStore: ViewStore(store))
        navigationController?.pushViewController(detailViewController, animated: true)
    },
    else: { [unowned self, navigationController] in
        navigationController?.popToViewController(self, animated: true)
    }
)

You can see the current effect in the screen recording. The desired effect is that all views get dark mode when any switch is toggled.

Sample project here:
Project.zip

3 Likes

I had same issue, the state update waits for the navigation to happen, but if you switch tabs, that never happens, so I just added an extra reducer to map the states when they are set, not when you navigate (or both). In your case:

let reducer: Reducer<AppState, AppAction, AppEnvironment> = Reducer.combine(
    firstReducer.pullback(state: \.firstView, action: /AppAction.first, environment: { $0 }),
    secondReducer.pullback(state: \.secondView, action: /AppAction.second, environment: { $0 }),
    .init { state, action, env in
        switch action {
            
            case .first(.detail(.toggle)):
                state.darkMode = state.firstDetailState!.darkMode
                return .none
            
            
            case .second(.detail(.toggle)):
                state.darkMode = state.secondDetailState!.darkMode
                return .none

            default:
                return .none
        }
        
    }
).debug()
1 Like

I also do something similar to what @pcolton suggested. I even give names to these reducers (such as darkModeReducer) to keep things easy to follow and refactor.

Thank you @kaishin and @pcolton for taking the time to answer this.
I understand what you're solving, and that's great, but it's not really what I'm looking for. Your solution only updates the global app state, not in optional states that are here used in the detail views.

We've looked through the example code provided in the repository, but we haven't found any examples where optionals are dependent on updates from the global state. There was an example showing how to use recursive states and actions, however they were only isolated states which are not dependent on a shared state.

The issue we have is that the global state is only propagated through state changes and not actions, which at the moment makes it impossible to invoke effects that updates the optional states with the shared states, which makes optional states not ideal for view models that has a shared state.

The optional state is used to drive navigation in this example, when it's nilled the detail view is dismissed.

I understand what you're solving, and that's great, but it's not really what I'm looking for. Your solution only updates the global app state, not in optional states that are here used in the detail views.

Indeed, after having a second look at the attached project I can see what you mean. I have dealt with a similar situation with user sessions. In my cases I went a totally different direction involving using environment instead, but to make minimal changes to your code, the snippet @pcolton shared can be updated to propagate the changes down with a bit of labor:

case .first(.detail(.toggle)):
  state.darkMode = state.firstDetailState?.darkMode ?? state.darkMode
  
  if state.darkMode != state.secondDetailState?.darkMode {
    return .init(value: .second(.detail(.toggle)))
  } else {
    return .none
  }
  
case  .second(.detail(.toggle)):
  state.darkMode = state.secondDetailState?.darkMode ?? state.darkMode
  
  if state.darkMode != state.firstDetailState?.darkMode {
    return .init(value: .first(.detail(.toggle)))
  } else {
    return .none
  }

It's not an elegant solution, and it would probably become too much if it's done more than twice. The solution involving environment and effects that I used isn't without flaws, but it worked better for me as the app started growing.