Replies: 4 comments 9 replies
-
Hey @LK Just saw your post over at Swift Forums :) Thanks for reminding me to check this out. I'll have a look later today and let you know what I think :) Awesome post! |
Beta Was this translation helpful? Give feedback.
-
Thanks for the write up! Sharing a global state across the app of >10 modules is also a challenge I am trying to work with. Considering all of the points you mentioned (testability, composition, etc.) how does context differ from environment? One can define a var on root environment like |
Beta Was this translation helpful? Give feedback.
-
When I just need to add a simple value to my state I use something like public func both<Whole, A, B>(_ a: @escaping (Whole) -> A?, _ b: @escaping (Whole) -> B?) -> (Whole) -> (A, B)? {
{
guard let va = a($0), let vb = b($0) else { return nil }
return (va, vb)
}
}
public func both<Whole, A, B>(_ a: @escaping (Whole) -> A, _ b: @escaping (Whole) -> B?) -> (Whole) -> (A, B)? {
{
guard let vb = b($0) else { return nil }
return (a($0), vb)
}
}
public func both<Whole, A, B>(_ a: @escaping (Whole) -> A, _ b: @escaping (Whole) -> B) -> (Whole) -> (A, B) {
{
(a($0), b($0))
}
} so I can do One downside is naming the parts of the module state which become a tuple of states. |
Beta Was this translation helpful? Give feedback.
-
What about Shared State from CaseStudies ? All our app is based on this mechanism for state sharing. As an example : extension BottomTabBarContainer.State {
var bottomTabBarState: BottomTabBar.State {
get {
.init(
theme: theme,
items: items,
selectedItemId: selectedItemId
)
}
set {
items = newValue.items
selectedItemId = newValue.selectedItemId
}
}
} |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
The Problem
A common problem that I run into is the need to have some state shared across many levels of the reducer/store hierarchy.
One option is to replicate the same state across multiple sub-states:
See code
This works, but now I need to make sure that I keep all of the copies of my
authenticatedUser
in sync. If my game state reducer updates some field on the user, my global reducer needs to remember to copy over the user state everywhere else, or I risk missing out on some state changes in other parts of my app. I end up losing some of the benefits of being able to compose reducers because my global reducer must be bloated with this excess logic for each copy ofauthenticatedUser
.A more promising approach is to retain a single source of truth for the shared state. If there's only one
authenticatedUser
hanging around, we no longer need to worry about keeping state changes synced up. We can even make some convenient helper functions — inspired bypullback
,scope
, and friends — to do the heavy lifting for us and eliminate some of the boilerplate.See code
Now, we can traverse up and down the state tree and take the context along as we go. When we use
scopeWithContext
, we're not converting fromStore<GlobalState, GlobalAction>
toStore<LocalState, LocalAction>
anymore — we can convertStore<Merged<Context, GlobalState>, GlobalAction>
toStore<Merged<Context, LocalState>, LocalAction>
. (The logic works similarly for converting reducers that operate onMerged<Context, LocalState>
to those that operate onMerged<Context, GlobalState>
usingpullbackWithContext
.)This works, but we run into a pretty annoying problem: if we need to use the context towards the bottom of the state tree, every store/reducer above it must also carry the context along with it. Since all (or most) of our stores/reducers now have a state type of
Merged<Context, State>
, a lot of the convenient helpers that TCA provides — think.optional
,.forEach
,IfLetStore
, etc. — no longer fit the bill. If we used to have a store that held on to something of typeOptionalState?
, we would useIfLetStore
to conditionally render something based on the nullity of the value in the store. But now, our store would hold on to something that looks likeMerged<Context, OptionalState?>
, and that no longer fits. We would need to reinvent every utility to understand our new state structure.The Solution
Instead of passing the context all the way up and down the state tree, we can borrow a page from React[1] and introduce the context anywhere we need it directly, without needing to pass it down through
scope
s andpullback
s. To do that, we introduce the concept of aContextHandle
— a global reference to our app's context — that our store and reducers can connect to as necessary. Here's what that might look like:Importantly, we can continue to scope down stores and reducers without passing along the context at every step. This means that all of the existing helper utilities will continue to work as they used to, but we can selectively plug in to the app context for cases where it's necessary. It also means that we can eliminate the number of state updates we need to deal with, since only the relevant features will get updated when the global context changes.
Testability
The introduction of a global uncontrolled dependency — the context handle — can make testing a challenge. There are two approaches we can use:
.withContext()
within the test itself. With this approach, each feature that relies on the app context will export a reducer that operates onMerged<Context, State>
. In the test, we can create a (local) context handle, call.withContext
to get a reducer that operates just onState
, and then run our tests from there. We will now have a local context that we can manipulate for our tests that won't interfere with other reducers, but this requires diligence to avoid accidentally leaking in a dependency on global state.One potential solution is to derive the context handle from the environment (in this case, the function signature of
Reducer.withContext
would change to take an argument of type(Environment) -> ContextHandle
). Combined with the SystemEnvironment approach, we can (partially) still alleviate the boilerplate of passing the context down every level of the hierarchy, but we can fully control the context handle from our tests. However the store's.withContext
would still require a global dependency. I'm open to thoughts/ideas!Composition
Using a single global context can also make it challenging to decompose an app into separate build targets. If the individual screens are each located in their own build target, they cannot depend on a global context that is defined in the main build target. However, they can define their own context handles for feature-specific shared data. For cases where the context type is shared across multiple features, we can just use a shared library that all build targets depend on.
The Conclusion
I have a fork of TCA that implements some of these ideas, but I'm open to hearing thoughts on what the best approach here would be. This has been one of the few and biggest pain points for me when building a large project around TCA, and I'm sure I'm not the only one, so excited to hear what others think.
-LK
Beta Was this translation helpful? Give feedback.
All reactions