I'm trying to wrap my head around handling side effects. Let's say I have a simple text editor. The user taps on a document to open it, makes a change, then taps on another document to switch to that.
Let's pretend saving takes 10 seconds for some reason. The user becomes impatient and starts tapping around and taps multiple times on other documents.
What needs to happen:
Before closing the current document, its changes need to be written to disk (side effect).
While the data is being written to disk, we must not allow switching to another document.
Once saved, the other document is presented to the user
On a high level, let's say we have an action .presentDocument. It returns a side effect to asynchronously save the currently presented document. Only when that completes is the document actually presented.
We could do this by having the .presentDocument action return a side effect that writes the current document to disk and once completed, returns another action of .actuallyPresentDocument which in turn shows the other document.
But how do we prevent other actions from being processed by the store while saving is in progress?
I am not sure if that is the most efficient of approaches... But it's the easiest to wrap your head around if all reducers need to know the same information (Brandons post).
Thanks a lot for your help! The login example sounds indeed very similar to saving something to disk in the background.
One downside applying this to my particular app is that all other views that could potentially trigger a state change need to be aware of isSaveToDiskInFlight and have to disable their UI accordingly. This could be required in a lot of places.
I'd like to use this in a Mac app so there is a toolbar and a menubar from which the user can trigger new state changes. When adding a new control, button, or menu item, I'd always have to consider: "Which in-flight actions are there and which should disable this control?" I'm concerned that I might forget this when adding a new control. Sounds like a central place to manage this is needed.
In my example of saving something to disk, I need to disable (almost) all user interaction with my app until that task completes.
Right now I literally hook into NSApplication.sendEvent() and prevent all key and mouse events while saving is in progress (not ideal). Having a central store through which all state changes are channeled seemed like a great way to prevent actions while waiting for a background process.
But this introduces more challenges: while user interactions are not allowed, side effects might return new state changes (e.g. present error alert), which must be allowed so I'd need a "parallel" track or mark certain state changes as "system" and allow those.
I see two angles to attack this problem:
From the Store side: UI remains fully responsive while background process is pending. The Store discards unrelated actions until the background side effect completes.
From the UI side: Block the UI to not allow the user to cause new actions to be sent to the store while a background operation is in progress.
I think #2 is the cleaner approach. Maybe I should rephrase my question to "How can I disable all controls in my app while a background operation is in progress?" This leads to having a global flag in the AppState something like isUserInteractionEnabled that is handled by the app itself.
If using SwiftUI, then if you set .disable(true) on the very root view that kicks off your application, it will effectively disable everything in your app. You can also scope it to smaller views, like say just the editor view. xAnd the disabled boolean can be driven off of your store's state. You can check if any of the *IsInFlight booleans are true, and if so flip the disabled flag:
struct AppView: View {
let store: Store<AppState, AppAction>
var body: some View {
WithViewStore(self.store) { viewStore in
NavigationView {
// lots of stuff
}
.disabled(viewStore.feature.saveIsInFlight)
}
}
}
Alternatively, if you truly do want to just prevent all/certain actions from ever entering the system when your app is in a specific state, then you can introduce a higher-order reduce that filters actions:
extension Reducer where State == AppState, Action == AppAction, Environment == AppEnvironment {
func disableWhenSaving() -> Self {
Self { state, action, environment in
guard state.feature.saveIsInFlight else {
// When not saving everything goes through...
return self.run(&state, action, environment)
}
switch action {
// Decide which actions should be allowed through...
}
}
}
}
And then you could tack on this transformation when created your root store:
let store = Store(
initialState: AppState(),
reducer: appReducer
.disableWhenSaving(),
environment: AppEnvironment(...)
)
Those are both very high level solutions. In reality you a bit of a blend of the two might be the sweet spot.
I'm using AppKit (it's a macOS app), but will be switching to SwiftUI & Catalyst hopefully soon.
Your idea for the higher-order reducer that filters action looks very promising. I think this combined with injecting some sort of catch-all responder into the Cocoa responder chain to make sure I also disable actions that don't go through the TCA (like the system provided text formatting and Edit menu commands) might be the answer.
Thanks for being so responsive and your helpful suggestions!
If you can make copy-on-write structure for your editor model, you can still allow editing while saving. Use snapshot of the model to save. Make saving in the background and then notify when done using one of the approaches suggested above.