Recently I've been building a React project that uses a lot of confirmation dialogs for the same purpose, but on multiple components or pages.
This ended up cluttering our container components every time we needed to confirm something.
To add these dialogs to different parts of the code (buttons, mostly) without having to copy-paste code, I created an abstraction.
For the code examples used, I used Chakra UI. If you haven’t heard of it or you want to deepen your knowledge about it, check this link. In order to keep things as technical as possible from now, I’ll be using the term 'modal' instead of calling it a dialog, since most React libraries do so. If this term is bizarre to you, please take a look at this useful resource before going further.
If you have to delegate React dialogs in a single part of the application, you probably won’t overthink it and implement it in a similar manner as shown below:
const Form = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const handleDelete = () => {
// delete logic
}
return (
<div>
<Button onClick={onOpen}>Delete</Button>
<Modal isOpen={isOpen}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Are you sure?</ModalHeader>
<ModalCloseButton />
<ModalBody>
Deleting this resource is an irreversible action. Are you sure you
want to proceed?
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>I've changed my mind</Button>
<Button colorScheme="blue" ml={3} onClick={handleDelete}>
Yes, delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
};
Alright, maybe we can refactor this a bit in order to keep our code a bit cleaner. Let’s say we move the modal outside of our container component:
const DeleteModal = ({ isOpen, onClose, onConfirmDelete }) => (
<Modal isOpen={isOpen}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Are you sure?</ModalHeader>
<ModalCloseButton />
<ModalBody>
Deleting this resource is an irreversible action. Are you sure you want
to proceed?
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>
I've changed my mind
</Button>
<Button colorScheme="blue" ml={3} onClick={onConfirmDelete}>
Yes, delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
And that leaves our container looking like this:
const Form = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const handleDelete = () => {
// delete logic
};
return (
<div>
<Button onClick={onOpen}>Delete</Button>
<DeleteModal
onConfirmDelete={handleDelete}
isOpen={isOpen}
onClose={onClose}
/>
</div>
);
};
This might be an acceptable state if you’re not big on modals throughout your application and it’s not worth it overthinking it further.
If you’re like me, this might not be the case.
Often times I found myself implementing this kind of modal in different situations, only to realize that all of them served the same purpose.
What if we could simplify it further?
Currently we have to track this modal’s visibility in its container, which slightly increases the cognitive complexity of the code.
The easy way out would be to just create a custom button that handles the modal when clicked on:
const DeleteButton = ({ onConfirmDelete }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<Button onClick={onOpen}>Delete</Button>
<DeleteModal
onConfirmDelete={onConfirmDelete}
isOpen={isOpen}
onClose={onClose}
/>
</>
);
};
This approach works just fine in most cases, but having too many delete buttons on one screen can add an unnecessary burden to the DOM.
And now brace yourself for this piece of code:
const ConfirmationModalContext = createContext();
const ConfirmationModalProvider = ({ children }) => {
const resolver = useRef();
const { isOpen, onOpen, onClose } = useDisclosure();
const handleAsk = () => {
onOpen();
return new Promise((resolve) => (resolver.current = resolve));
};
const handleConfirm = () => {
resolver.current && resolver.current(true);
onClose();
};
const handleCancel = () => {
resolver.current && resolver.current(false);
onClose();
};
return (
<>
<DeleteModal
isOpen={isOpen}
onClose={handleCancel}
onConfirmDelete={handleConfirm}
/>
<ConfirmationModalContext.Provider value={{ ask: handleAsk }}>
{children}
</ConfirmationModalContext.Provider>
</>
);
};
const useConfirmation = () => {
return useContext(ConfirmationModalContext);
};
Whoa! Whoa! Let’s take a look at it and try to understand what happens here.
We’ve moved the modal into the provider component which we’re going to wrap the application with. That’s also the place where we keep the modal’s visibility state.
In order to be able to use it within any component we want, we also need to create a hook for it. This hook lets us store a promise within a ref that can either be resolved or rejected whenever the user clicks the 'Confirm' or 'Cancel' button. Cool, right? This is how we would use this hook inside our delete button:
const DeleteButton = ({ onConfirmDelete }) => {
const { ask } = useConfirmation();
const handleClick = async () => {
const confirmed = await ask();
if (confirmed) {
onConfirmDelete();
}
}
return <Button onClick={handleClick}>Delete</Button>;
};
And voila! All set.
I hope you have fun with my solution for delegating React dialogs and you will implement it successfully within your project (GitHub repo available here).
Hint: if you have questions or ideas for improvement, I cannot wait to read them in the comments section☺️.
And if you learned something this time, maybe you want to learn some more by subscribing to the Around25 newsletter (check below). No spammy content, just solid tech bites and tutorials from our side.