...and the interactionContext handles back button clicks and makes sure the dialog is cancelled and our history is kept clean.
It even works with nested interactions (i.e. the dialog can start a new interaction in place). Again, the user can click the back button repeatedly to back out of the interaction and everything just works.
It's simple, you can place a component that provides dialogs near the root of your component tree and provide async functions which open such dialogs to the descendant components (e.g. via contexts). These functions can then be made to resolve only when the dialogs are closed. Thus, all descendant components can use dialogs in the same manner as shown in your example.
You can still do this with React, though. It's quite easy (and useful!) to have components which implement some dialog which pops up when you call an async function, which only resolves once the user has finished interacting with that dialog. The code could, for example, be essentially identical to your example. It also isn't in contradiction with the nature of React at all, projects that use React are full of imperative code. React just frees you from having to maintain the connection between UI state and your data manually in an imperative manner.
That's a display, read, react loop. Yes, it's imperative, but this loop is always imperative. Get a FRP GUI toolkit in Haskell and you'll see that loop there, imperative.
The difference between functional and imperative code is there existing more than one such loop available at the same time, and in how your functions are implemented.
A dialog should return a promise. Then you can mix confirmation dialog, fetch calls and so on like this:
Yes, that's imperative code, which violates React principles. But it is insanely simple compared to typical React code.