In this article I am looking back at how we at Prezly have come up with a brilliant idea of simplifying modal interactions in React, which later became an open source package: @prezly/react-promise-modal.

The Ugly
It was July 2018. We at Prezly went full in on rebuilding the CRM UI in React. We were using React 16 (pre-hooks) + Redux at the time, no TypeScript, and modals were a huge pain in the ass. Especially confirmations and data prompts surrounding data management API requests. Moreover, The Redux Way™ made these things even more complicated.
For a confirmation to appear, you had to add the open/closed flag to the state, naturally. But the one simple workflow always had to be split into at lease three callback handlers: one before, one after the confirmation, and one to dismiss the modal.
This is how it would look like in a React 16 class component. Even without stepping into Redux action reducer and dispatcher yada-yada, this trival problem is already more complex than anyone would ever want it to be:
// Clicked a button for an irreversible destructive action.
// E.g. "Delete account".
handleDangerousActionClick: () => {
// Begin the workflow
this.setState({ showConfirmation: true });
}
// Confirmed by clicking "Yes, I'm sure" in the confirmation modal.
handleConfirmed: () => {
this.setState({ showConfirmation: false });
// Continue the workflow
this.performDangerousAction();
}
// Clicked "Cancel" in the confirmation modal.
handleCancelled: () => {
this.setState({ showConfirmation: false });
}
And this is just a trivial yes/no confirmation workflow. Having a multi-step workflow with a chance of spawning a confirmation or a data prompt modal in between of steps, having more input variables for the modal coming from a previous step was always resulting in a mess.
What could have been written with 20 lines of almost linear code
using window.confirm()
and window.promt()
would become a blob of code with the flow
fragmented over 10 or even 20 different callback functions, juggling dozens of state variables.
The code that is difficult to write and difficult to read will always be a source of bugs.
In contrast, in vanilla JS using the browser's native API (see window confirm() method) this logic would be much, much simpler, and also would remain a single logical flow:
handleDangerousActionClick: () => {
if (window.confirm('Are you sure?')) {
this.performDangerousAcount();
}
}
Yes, that's it. IT'S. THAT. SIMPLE.
The Nice
I'm a big sucker for elegant abstractions. Once you invent one, you always start wondering how you could live without it all these years. For me, that's one of the most difficult parts of software development. And also the most rewarding. Good abstractions stick for years, the best ones stick for decades.
So that's what I wanted to achieve — the simplicity of using the native browser APIs:
alert()
, confirm()
, and prompt()
. But still being able to build their UI in React,
keeping them intact with the product design language
(and not the characterless averageness of the default browser UIs).
This is how @prezly/react-promise-modal was born.
The idea was very simple, actually, — you call an async function (reactPromiseModal()
),
providing it with the modal renderer callback. It creates a new React rendering root,
mounts your modal into it, and provides it the callbacks: onConfirm
and onDismiss
.
Once the modal calls one of the resolution callbacks, it gets unmounted and destroyed,
while the promise returned from reactPromiseModal()
resolves to true
or false
.
It even managed the open/closed async lifecyle, allowing the modal to have in and out
transitions with very little effort.
And because its API was imperative, it was super easy to use. Just like the browser's native
confirm()
and prompt()
APIs:
// Class component method:
handleDangerousActionClick: async () => {
if (await reactConfirm('Are you sure?')) {
await this.performDangerousAcount();
}
}
// Defined outside of the component, in the shared lib
async function reactConfirm(title) {
return await reactPromiseModal((props) => <ConfirmationModal {...props} title={title} />);
}
This approach became a game changer! Dramatically simpler, super elegant, easy to read, and easy to write. Exactly what I was looking for!
I kid you not: this exact image our most hardcore frontend developer has posted in the code review for my pull request introducing this new functionality:

While being hilarious, this reaction indicated how excited my teammates were about this new abstraction. Just as I was!
We, in the Prezly product dev team loved this new API and started to gradually rewrite previously implemented components to it.
Next year, in March 2018, we've published it as an open source package
under the MIT license:
However, there was a fly in the oinment. Of course. The more complex logic we've been converting to this new approach, the more apparent became the problems with it.
In my next post I'll go into details on the problems we've encountered with this approach and how we've fixed it.
Read it here: Fixing React Promise Modals — The Nice and The Proper
Cheers! 🖖