What it's the recommended way to support multiple windows without sharing state?

Im currently working with iOS14b4 and Xcode 12b4 and given this basic setup, it can be seen that any change on AppState it's propagated to every scene instances.
So my question is if anybody knows a solution that avoids the unwanted store sharing on the multi window scenario.

struct AppState: Equatable {
  var searchQuery = ""
}

enum AppAction: Equatable {
  case searchQueryChanged(String)
}

struct AppEnvironment {
  var mainQueue: AnySchedulerOf<DispatchQueue>
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
  switch action {
  case let .searchQueryChanged(query):
    state.searchQuery = query
    return .none
  }
}
struct ContentView: View {
  
  let store: Store<AppState, AppAction>
  
  var body: some View {
    WithViewStore(store) { viewStore in
      TextField(
        "Berlin, Amsterdam, ...",
        text: viewStore.binding(
          get: { $0.searchQuery },
          send: AppAction.searchQueryChanged
        )
      ).padding()
    }
  }
}
@main
struct MultiWindowApp: App {
  
  let store = Store(
    initialState: AppState(),
    reducer: appReducer,
    environment: AppEnvironment(mainQueue: DispatchQueue.main.eraseToAnyScheduler())
  )
  
  var body: some Scene {
    WindowGroup {
      ContentView(store: store)
    }
  }
}
3 Likes

Did you ever find the solution? I would have thought moving the store initialization to the ContentView itself it would cause two states to be created.

Could we try passing a scoped store using a forEach from an IdentifiedArray as a parameter to the content view? Keyed somehow to the tag or id of the instance of the WindowGroup?

Running into this issue as well. Did you ever find a fix?

Yes, still figuring out a more elegant solution, but basically if you store your "TCA state" on a property @State inside a children of the view hierarchy (and not on the first level), then you can use it for constructing independent stores when instantiating windows.

import SwiftUI
import ComposableArchitecture

@main
struct TCAMultiWindowApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

struct AppState: Identifiable, Equatable {
  let id: UUID //Only used for demo purposes
  var color: Color = .clear
}

enum AppAction: Equatable {
  case changeColor(Color)
}

struct AppEnvironment {}

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { (state, action, _) in
  switch action {
  case let .changeColor(color):
    state.color = color
    return .none
  }
}

struct ContentView: View {
  
  // This is what will allow an independent state for each window
  @State var appState: AppState? = nil
  
  var body: some View {
    if let state = appState {
      
      let store = Store(
        initialState: state,
        reducer: appReducer,
        environment: AppEnvironment()
      )
      
      WithViewStore(store) { viewStore in
        VStack {
          Text(state.id.description).background(viewStore.color)
          
          Button("Change Color") {
            viewStore.send(.changeColor(.pink))
          }
        }
        .padding()
      }
      
    }
    else {
      Spacer()
        .onAppear {
          appState = AppState(id: UUID())
        }
    }
  }
}

Tried also combined with mathieutozer idea (using IdentifiedArray and ForEachStore) and also works with the same @State on children technic and even if it gives you centralised control on the windows, in my experience its rather easy to enter on recursion.

Note that I've mostly focused on making this work on macOS, so I didn't tested properly yet on iOS.

The key reference for me has being this mention on the WindowGroup documentation:

Every window created from the group maintains independent state. For example, for each new window created from the group the system allocates new storage for any State or StateObject variables instantiated by the scene’s view hierarchy.

2 Likes

Isn’t the issue here that every time a new window is created, each new ContentView is referencing the same store?

Couldn’t you just have a factory function, e.g. “makeStore()” that returns a new store each time and use that when injecting your store into the ContentView?

That didn't work for me

In what way didn’t it work?

It works for me.

1 Like

Given that hint in the WindowGroup documentation, what about the following:

extension Store: ObservableObject
  where State == MyRootViewState, Action == MyRootViewAction { }

struct MyRootView: View {
  @StateObject var store: Store<MyRootViewState, MyRootViewAction>

  // ...
}

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      MyRootView(
        store: .init(
          initialState: // ...
          reducer: // ...
          environment: // ...
        )
      )
    }
  }
}

Basically, a marker conformance to tell SwiftUI each root view in the scene should have an independent instance of the store property? Caveat emptor: I am unsure if this has other side effects I am not aware of. Just an idea I had, as I am encountering the same issue right now.

Hi!

I'm little late to the party, so sorry for unearthing this thread. I've recently released a library (composable-effect-identifier) that helps handling multiple stores in the same process, as Effect identifiers from different instances may coincide, and stores may cancel effects from other instances otherwise (long-living effects are kept in a dictionary keyed by the Effect identifier internally).
The library uses a property wrapper to define Effect identifier, and automatically "namespace" them with some store-specific data. As a result, each root store has its own subset of cancellable identifiers and works in isolation from the others.

It doesn't directly fix the issue for vending a new root store instance to each Window/DocumentGroup, but it ships with one possible solution for setting up a document-based app as an example. There are probably many ways to achieve this though.