How to wait for an Effect (e.g. write to disk) before allowing a new state change?

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?

Thanks a lot in advance!

You could possibly use a piece of state for that?

struct DocumentState {
     var isPersistingDocuments: Bool
}

Before you start to async save these documents you can update this piece of state. In the documentReducer you can at least observe that now.

if other reducers need access to this state as well, you can use a derived state. Something like Brandon explained here: Best practice for sharing data between many features? - #4 by mbrandonw

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).

Yeah, I think @VinceMikhailKearney has the right idea, and we even have a few examples of this in some of our example apps.

In the TicTacToe app we disable the login form while the login request is "inflight". We do this by storing a isLoginRequestInFlight boolean in state:

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/Sources/Core/LoginCore.swift#L11

Which we set to true/false in the reducer when the API request is made and when it returns with a response:

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/Sources/Core/LoginCore.swift#L67-L85

And then finally we use that boolean in the view to disable the form and show an activity indicator:

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/Sources/Views-SwiftUI/LoginSwiftView.swift#L98-L99

Does that help?

1 Like

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:

  1. From the Store side: UI remains fully responsive while background process is pending. The Store discards unrelated actions until the background side effect completes.
  2. 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.

Are you using SwiftUI or UIKit?

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.