Hello, this is the first technical article I am writing since we started developing fouriviere.io; for more info about the Fourier project, please visit fourviere.io.
The problem I want to discuss regards the confirmation modal; we have a few of them in our most complex flows (e.g., feed sync, feed/episode deletion).
Having a confirmation modal is often a good practice for managing un-revertable or destructive actions, and we adopted it in our critical paths for protecting the user from accidental actions.
Our frontend is built with React, and one of React's peculiarities is its very declarative approach, an approach that contrasts with the imperative approach of the confirmation modal. Considering this, our initial implementation bypassed the obstacle by effectively circumventing it; in fact, we used the tauri dialog function, which mimics the web api confirm method confirm method in a certain way.
//...do something
const confirmed = await confirm('This action cannot be reverted. Are you sure?', { title: 'Tauri', type: 'warning' });
if(!confirmed){
//...exit
}
//...continue the action
This is cool because it can be used in complex workflows without fighting with components and complex states; in fact, we don't need to track whether the modal is shown or the confirmation button is pressed.
However, there is a downside: the design of this confirmation modal comes from the operating system and does not fit our design styles at all.
How we solved the problem
First of all, we designed a confirmation modal, for laziness we based our component on the tailwindui dialog .
Here is an oversimplified version. If you want to see the implementation with the tailwind classes, please look at our ui lib
type Props = {
ok: () => void;
cancel: () => void;
title: string;
message: string;
okButton: string;
cancelButton: string;
icon?: React.ElementType;
};
export default function Alert({ok, cancel, title, message, okButton, cancelButton, icon}: Props) {
const Icon = icon as React.ElementType;
return (
<div>
<h3>{icon} {title}</h3>
<p>{message}</p>
<div>
<button onClick={ok}>{okButton}</button>
<button onClick={cancel}>{cancelButton}</button>
</div>
</div>
);
}
Now, we need to display this Alert modal in a portal in the most imperative way possible. To do that, we created a hook that exposes an askForConfirmation method that does all the dirty work under the hood.
interface Options {
title: string;
message: string;
icon ?: ElementType;
}
const useConfirmationModal = () => {
async function askForConfirmation({title, message, icon}:Options)
{
//Here we will put our implementation
}
return {askForConfirmation}
}
export default useConfirmationModal;
This hook will return an askForConfirmation
method for being called by the component logic, this method takes a Options
object for defining the modal title, message and icon.
Now we need to track when modal is displayed and eventually the title
, message
, icon
, the okAction
and the cancelAction
, we define a state for the component, the state can be false or object of type ModalState
, if false the modal is hidden.
interface Options {
title: string;
message: string;
icon ?: ElementType;
}
interface ModalState
title: string;
message: string;
ok: () => void;
cancel: () => void;
icon?: ElementType;
}
const useConfirmationModal = () => {
const [modal, setModal] = useState<false | ModalState>(false);
async function askForConfirmation({title, message, icon}:Options)
{
//Here we will put our implementation
}
return {askForConfirmation}
}
export default useConfirmationModal;
Now the askForConfirmation
method should set the modal state, let's implement. But we want that does it following an async approach using promises, like that we can call in this way
//inside the component//
const {askForConfirmation} = useConfirmationModal()
//...previous logic
if (!await askForConfirmation()) {
return
}
continue
This means that askForConfirmation should return a promise that is resolved (with true or false) when the ok button is pressed or when the cancel button is pressed; before resolving the promise, the modal is hidden.
interface Options {
title: string;
message: string;
icon ?: ElementType;
}
interface ModalState
title: string;
message: string;
ok: () => void;
cancel: () => void;
icon?: ElementType;
}
const useConfirmationModal = () => {
const [modal, setModal] = useState<false | ModalState>(false);
async function askForConfirmation({title, message, icon}:Options)
{
return new Promise<boolean>((resolve) => {
setModal({
title,
message,
icon,
ok: () => {
setModal(false);
resolve(true);
},
cancel: () => {
setModal(false);
resolve(false);
},
});
});
}
return {askForConfirmation}
}
export default useConfirmationModal;
Now stays to implement the display part. This is a hook, and it does not render directly jsx; then we need to find a "sabotage" for managing the render phase. What if the hook returns a function component for rendering it?
Let's try.
interface Options {
title: string;
message: string;
icon ?: ElementType;
}
interface ModalState
title: string;
message: string;
ok: () => void;
cancel: () => void;
icon?: ElementType;
}
const useConfirmationModal = () => {
const [modal, setModal] = useState<false | ModalState>(false);
const modals = document.getElementById("modals") as HTMLElement;
async function askForConfirmation({title, message, icon}:Options)
{
return new Promise<boolean>((resolve) => {
setModal({
title,
message,
icon,
ok: () => {
setModal(false);
resolve(true);
},
cancel: () => {
setModal(false);
resolve(false);
},
});
});
}
function renderConfirmationModal() {
return (
<>
{modal && createPortal(
<Alert
icon={modal.icon ?? ExclamationTriangleIcon}
title={modal.title}
message={modal.message}
okButton="ok"
cancelButton="cancel"
ok={modal.ok}
cancel={modal.cancel}
/>,
modals,
)
}
</>
);
return {askForConfirmation, renderConfirmationModal}
}
export default useConfirmationModal;
Now, our hook returns aside the askForConfirmation
, a function component renderConfirmationModal that displays the modal in the portal (in our case, inside the <div id="modal">
in the HTML page).
Now, let's try to use it in a simple component
export default function SimpleComponent() {
const {askForConfirmation, renderConfirmationModal} = useConfirmationModal()
async function doSomething() {
if(!askForConfirmation({
title: "Are you sure?",
message: "This operation cannot be reverted",
})) {
return false
}
//do stuff...
}
return <>
{renderConfirmationModal()}
<button onClick={doSomething}>DO IT/button>
</>
}
Conclusions
After this journey, we have a hook that helps us to have a confirmation modal with a simple api. It is essential to keep simple and reusable parts of the UI; this helps to keep the code readable, and we know how it can become messy our react components.
But keeping things simple needs complex effort.
Top comments (0)