Is `Environment` being initialized for every action fired intentional?

The Environment is a great concept, but in practice I've been having some trouble working with it.

Example:
Let's say we have the following two modules Foo and FooDetail. Foo is the parent of FooDetail so FooDetail operates on a scoped version of Foo's store (and we have the appropriate pullback setup in Foo's reducer to subscribe to FooDetail's events.

We want to subscribe to Reachability events in both Foo and FooDetail, so this seems like the perfect time to use PathMonitorClient from your Designing Dependencies: Reachability episode. Now the question is where does this dependency live and how do we get access to it within our Reducer to handle reachability events?

Problem with Environment:

Although intuitively it might make sense to put this dependency in the environment, it actually ends up not being such a good idea because of how often the Environment's .init gets called (a consequence of initializing the environment within a closure that is never retained). This ends up creating some constraints in my implementation that seem less than ideal and for me work against my intuition on how I would expect the Environment's lifecycle to work. My implementation has the subscription happening right when the view first appears, but when the Store gets sent a follow up action, the subscription gets cancelled because the Environment.init gets called, resulting in a new instance of PathMonitorClient. Problem is now the subscription is gone and no event is resubscribing to PathMonitorClient.

Ideal scenario:

Subscribe to PathMonitorClient only once (on initialization of Foo/FooDetail respectively). Rely on PathMonitorClient to cancel the subscription whenever receiveCancel is called on the PassthroughSubject within the combine publisher, which would happen when the view is deallocated. This would require the Environment's lifetime to be as long as the Store's lifetime.

To get the behavior I'd expect it would require that I end up putting the PathMonitorClient within state rather than having it in the Environment. Alternatively I could either A) Make PathMonitorClient a singleton or B) Make it a class type and have it's reference held in memory by something that isn't the Environment. Downsides to A and B are that I'd have to manage the unsubscribe myself vs allowing ARC to handle it for me in a way that I would expect.

Question:
Why is Environment designed to be initialized so many times, has there been any thought to maybe change the lifecycle of it? And a follow up: what are the recommended use cases for storing things in the Environment? Seems to me that right now only singletons and cheap stateless dependencies are what make sense?

1 Like

How are you initially creating your store? Can you give a code example? PathMonitorClient.live() is a function, so if you aren’t storing its result somewhere then you will always have to call it again to get a new value. But if you are storing the result in your environment, then it shouldn’t be a problem.

My environment typically looks something like this (excuse the formatting, I’m typing on my phone):

struct MyEnvironment {
  var pathMonitorClient: PathMonitorClient
}

Then I initialize my store when the app starts up:

let store = Store(state, reducer, MyEnvironment(pathMonitorClient: .live(queue: .main)))

The environment, along with the pathMonitorClient, are initialized once and stored in the store. I haven’t tested the above, but I don’t see why that wouldn’t work. You are seeing things get initialized more than once?

You mention you are initializing your environment in a closure? Can you provide an example?

1 Like

We are running into this issue when we call store.scope and create a view, FooDetailState, from FooState. A small example would be

// MARK: - State
struct FooState {
    var detail: FooDetail
}
struct FooDetail { ... }

// MARK: - Environment
struct FooEnvironment {
    let client: PathMonitorClient
}
struct FooDetailEnvironment {
    let client: PathMonitorClient
}

The closure we're referring to is provided in the pullback of fooDetailReducer

let fooReducer: Reducer<FooState, FooAction, FooEnvironment> = Reducer.combine(
    fooDetailReducer
    .pullback(
        state: \.detail,
        action: ...
        environment: { env in 
            .init(client: env.client) 
        }
    ),
    ...
)

Then in the view we'd have something like

/// FooView
var body: some View {
...
    FooDetailView(store: store.scope(state: \.detail, action: FooAction.detail)
...
}

What we're noticing is that each time an action is sent to the Store and processed in the reducer, the breakpoint set on the closure environment: { env in .init(client: env.client) } is getting hit--leading us to believe a new instance of FooDetailEnvironment is being created.

Oh I see what you are saying. Yeah it is creating a new instance of your locally scoped environment from your more globally scoped environment, but it is passing the fully constructed instance of PathMonitorClient from your more global environment (FooEnvironment). So you shouldn’t be seeing PathMonitorClient.init getting called at that point.

The one thing I might look out for is to make sure you are including all of your dependencies in your top level environment (ex. AppEnvironment). Then AppEnvironment would pass the fully constructed dependencies down to the locally scoped environments through pullbacks. I would make sure you aren’t doing something like:

barReducer.pullback(
  ...
  environment: { _ in BarEnvironment(pathMonitorClient: .live()) }
)

Calling .live() in a pullback will always create a new one whenever that closure is called. Does that make sense? Are you seeing different behavior?

1 Like