Shared global state with infinite derived children

Hello guys, I was unable to solve my problem so far, so thanks for any advice in advance!

Let's say I have two modules - Shared and Local.
SharedState should act like a global state, holding playingId for the whole app lifecycle. LocalState has it's id, isPlaying boolean which should be true if playingId in SharedState (Store) equals to that LocalState id. The problem is that there can be infinite instances of LocalState, even multiple instances with same id and I need to have isPlaying property in sync with SharedState's playingId property + LocalAction.play should map to SharedState.play(String) with LocalState.id and LocalAction.stop to SharedAction.stop. I hope my explanation is understandable, and thanks once more for any advice.

// MARK: - Shared
struct SharedState: Equatable {
	var playingId: String?
}

enum SharedAction: Equatable {
	case play(String)
	case stop
}

let sharedReducer: Reducer<SharedState, SharedAction, Void> = .init { state, action, _ in
	switch action {
	case .play(let id):
		state.playingId = id

	case .stop:
		state.playingId = nil
	}

	return .none
}

// MARK: - Local
struct LocalState: Equatable {
	let id: String
	var playing = false
	var data: String?
}

enum LocalAction: Equatable {
	case play
	case stop
	case set(String?)
}

let localReducer: Reducer<LocalState, LocalAction, Void> = .init { state, action, _ in
	...
}

Have you thought of using swift combine to send from Shared to Local?

State that needs to be accessible through every layer of your application is best handled as a dependency in the environment that has synchronous access to the playing id, as well as endpoints for playing and stopping:

struct PlayingClient {
  var id: () -> String?
  var play: (String) -> Effect<Never, Never>
  var stop: () -> Effect<Never, Never>
}

Then a live version of this client could manage this state:

extension PlayingClient {
  static var live: Self {
    var id: String?
    return Self(
      id: { id },
      play: { newId in 
        .fireAndForget { id = newId } 
      },
      stop: { 
        .fireAndForget { id = nil } 
      }
    )
  }
}

And then a reducer can use this dependency to play, stop or figuring out if it is currently playing:

let reducer: Reducer<State, Action, Environment> = .init { state, action, environment in
  switch action {
  case .onAppear:
    state.isPlaying = state.id == environment.playingClient.id()
    return .none

  case .play(let id):
    return environment.playingClient.play(id).fireAndForget()

  case .stop:
    return environment.playingClient.stop().fireAndForget()
  }
}

And once you've done all of this you will be in a good position to write tests with a test version of the PlayingClient.

6 Likes

Thank you! I had something similar in my mind but couldn't get it together. I was still hanging on using state with some trickery with stores/reducers but using it as dependency in environment looks reasonable. I just edited PlayingClient a bit and reducer accordingly, so it can react to state changes

struct PlayingClient {
	var id: () -> Effect<String?, Never>
	var play: (String) -> Effect<Never, Never>
	var stop: () -> Effect<Never, Never>

	static var live: Self {
		let id: PassthroughSubject<String?, Never> = .init()

		return .init(
			id: {
				id.eraseToEffect()
			},
            ...
		)
	}
}
2 Likes

Very nice. I meant to mention about possibly upgrading id to be an effect for when you need to be notified of changes. Glad you stumbled upon that!