apocalypse

dev

Brutally simple confirm modal mechanism in React

#Introduction

You heard that right - brutally simple. Both in usage and implementation. Before I jump to code snippets, I want to tell a story of where does this need for a simple confirm mechanism comes from. Recently I've been working on an admin panel for my blog. Admin panels are generally filled with many CRUD interfaces, and I want to focus on the D, especially. I mean, the letter. Yeah. Off to a great start!

I think it's a long-established standard for delete operations to come with a confirmation, before any action is taken, the simplest reason being accidental clicks. You don't want to fire a destructive action just like that, because your user may have missclicked. Alright, but how to implement confirm modals, or generally modals in React? I may be wrong, but I am yet to come across a solution that would look remotely satisfying. I listed some of the most common ones down below, and I think they all have their flaws, the biggest one being complexity. You have to write a lot of boilerplate to get going and if you want to extend this part of functionality later, you may be caught up in a greasy spaghetti that you barely even remember how to tame.

#Confirm dialog - what are the options?

I don't want to spoil anything, but the subtitle may have already done this for me. You may now have the built-in global window.confirm function in mind. That's a good call, and that's where I went with my solution, but before I show you the remedy, I want to show you why it is needed in the first place.

#The "classic" React approach

What I mean by "classic" is using built-in hooks and prop drilling to get the desired effect. It would probably look something like that:

items-list.tsx
interface Item { id: string; name: string; value: string; } function ItemsList({ items }: { items: Item[] }) { const [isOpen, setOpen] = useState(false); const [dataForModal, setDataForModal] = useState<Item>(); const openConfirmModal = (item: Item) => { setDataForModal(item); setOpen(true); } const closeModal = () => { setOpen(false); setDataForModal(undefined); } return ( <> {items.map(item => ( <Item key={item.id} data={item} onDelete={openConfirmModal} /> ))} <Modal isOpen={isOpen} onClose={closeModal}> <DeleteItemModalLayout dataForModal={dataForModal} onClose={closeModal} /> </Modal> </> ) }

Treat this as a semi-pseudocode, I only focused on the things that matter to the case. I also want to avoid large code chunks for this part of an article (there will be much more code later), so I will only briefly describe the remaining components that ItemsList uses in order to make this whole thing work.

  1. The <Item /> component would render its data with a trash icon button somewhere, that would fire the onDelete handler.

  2. The <Modal /> is a generic component that gets the modal shell rendered on the page (probably via the Portal API). Its content is whatever you provide via the children prop. When you click on the overlay or hit the Escape key, it will fire the onClose callback.

  3. The <DeleteItemModalLayout /> is a component that displays a confirmation message with action buttons for confirm and cancel. Upon confirmation, it fires the actual API call to remove an item, based on the data provided via dataForModal prop. It also handles cancelation by firing the onClose callback. You could pass the confirmation action callback as a prop, but that isn't the most important thing here - I have intentionally hidden the removal logic, not to favor any particular async state management/data mutation solution.

I guess you could move things around a little bit, to make it prettier, but there are some parts that would not be pretty, no matter how hard you try. ItemsList has to keep a state related to a modal being opened or closed, as well as a logic for opening and closing the modal. The component, whose name suggest that it is responsible for displaying a list of items, is also actually responsible for synchronizing the whole modal logic within. It's not the end of the world, but it feels just a little bit wrong. On top of that, there are actually 3 individual components, where the deletion logic is spread across, each containing a different aspect of it (firing action, confirming/cancelling action, synchronizing the UI). I feel like we can do better.

#Context API

When I was wondering how to improve this confirmation situation, the Context API was one of the things that crossed my mind. The motivation for Context would be not having to prop-drill data and callbacks left and right, like with the classic solution, and also decoupling modal logic from the rest of component logic. It's tempting and there are two approaches that I can think of, that utilizes the Context API, but they both seem like a step in a wrong direction.

The first approach is this: you have one global context (🚩) for confirm modals, that wraps the entire app and renders children along with the Modal component. The entire modal logic will be encapsulated in the Context provider component. To open this modal, you call the openModal() function from Context in whatever place you wish in your app. Calling this function, you will provide arguments for title, message, as well as the callback for confirmation, which will set proper state inside the Modal and show it on the screen.

I think we're past the era of redux-form, redux-modal or other abominations utilizing the global store for everything, and we know why keeping relatively local logic in a global state is a bad idea. Even if you fix the redundant re-renders issue with some smart yet complex memoization, you still have an architectural flaw, where a large part of your application is enriched with data that it doesn't need.

The second approach would be to create many local Contexts instead, wherever you want to use a confirmation dialog. Each of these Contexts would work roughly the same, so you could probably use a factory pattern for them. It may be better from a performance and architectural standpoint, but it would require a tone of boilerplate every time you want to implement a simple confirmation dialog. I view this as an inconvenient solution as well as an overkill in most situations. I would consider this solution, though, if the delete button would be a few levels below the root List component in the components tree, just to avoid the prop drilling.

#window.confirm

Now that we have the boring and/or complex solutions out of the way, let’s scratch all that. Let's talk a bit more about this bad boy. It's brutally simple, but also painful to look at. But it gets the job done like no other. You just call confirm('Are you sure') and the whole page stops executing until you make a decision.

const shouldRemove = confirm("Are you sure?"); // :boolean // this code won't execute until the confirm is closed if (!shouldRemove) { return; }

It would be nice to recreate such a behavior in React, so that the modal would not look like ass, but still maintain this OP feature of pausing further code execution until you make a final decision. Whenever I'm faced with a challenge like this, I try to think from the end-user perspective first. How do I want to use this mechanism? Answering this question would be crucial to have a clear goal in mind during the actual development.

async function removeButtonHandler(item: Item) { const shouldRemoveItem = await confirm(`Do you want to remove ${item.name}?`); if (!shouldRemoveItem) { return; } // actual removal logic }

The most important part here is the confirm function. Its job is to open the modal and (a)wait until I click either Yes or No. To make it happen, the confirm function should return a Promise, that will resolve with either true or false, depending on which button was clicked. While it seems a bit complicated, it's actually quite simple. Let's look at the implementation:

#useConfirm custom hook

use-confirm.ts
type Resolve = (value: boolean) => void; export default function useConfirm() { // we need to persist resolve function from the confirm's Promise in the state const [resolvePrompt, setResolvePrompt] = useState<Resolve>(); // that's a simple state for opening and closing modal const [opened, setOpened] = useState(false); // that's the key logic here - a confirm function that returns a Promise<boolean> const confirm = () => new Promise<boolean>((resolve) => { // we push resolve to the state, so that we can use it later setResolvePrompt(() => resolve); // and we open the modal setOpened(true); }); const onConfirm = () => { // upon confirmation we resolve the Promise with true, resolvePrompt?.(true); // clear the Promise, setResolvePrompt(undefined); // and close the modal setOpened(false); }; const onCancel = () => { // upon cancelling we do roughly the same, // only with a false value resolvePrompt?.(false); setResolvePrompt(undefined); setOpened(false); }; // where we attach onConfirm and onCancel is another problem // that I will discuss soon return { confirm, } }

I hope the comments were clear enough, but if you need further explanation - calling confirm function returns a Promise, that won't be fulfilled until either onConfirm on onCancel function gets executed.

"But wait! Where's the modal??" - you may ask. That's a terrific question. We have the onCancel and onConfirm callbacks, but where are the buttons, that would these be attached to? My approach as to how and where the Modal should be rendered has changed in the course of writing this article.

#First approach

When I started the implementation, I thought that I could just return the JSX of the modal along with the confirm function from the hook. That way, I would encapsulate all the logic and UI within just one hook. While it was tempting and it could work, that would also be an architectural flaw - hooks are generally not supposed to render any content on their own. They can do it, but it will cause issues with code readability and maintainability, could result in a broken components tree, as well as a potential performance degradation. I don't want to trade one bad design for another.

#The proper approach

The "clean" way to do it, is to separate logic and UI, but make them co-dependent. We leave the useConfirm hook, but we also add the ConfirmModal component, that would take opened, onConfirm and onClose props from our hook. That way, we still have the neat async confirm function, but without the issues mentioned in the previous paragraph. This way, we can also configure the UI of the modal (e.g. the title), via props, independently of the hook. The tradeoff here is that we now have to import both the hook and the component, whenever we want to display a modal, but, like, that's how it's done with React, baby. Hard to do it any better, respecting React principles.

#The code

Starting with the hook, that contains all the logic:

use-confirm.ts
import { useState } from 'react'; type Resolve = (value: boolean) => void; export default function useConfirm() { const [resolvePrompt, setResolvePrompt] = useState<Resolve>(); const [opened, setOpened] = useState(false); const confirm = () => new Promise<boolean>((resolve) => { setResolvePrompt(() => resolve); setOpened(true); }); const onConfirm = () => { resolvePrompt?.(true); setResolvePrompt(undefined); setOpened(false); }; const onCancel = () => { resolvePrompt?.(false); setResolvePrompt(undefined); setOpened(false); }; return { opened, confirm, onConfirm, onCancel, }; }

Now, there's the Modal component (stripped from the CSS to avoid distraction):

confirm-modal.tsx
interface ConfirmModalProps { opened: boolean; onConfirm: () => void; onCancel: () => void; } export function ConfirmModal({ opened, onConfirm, onCancel, }: ConfirmModalProps) { return ( <Modal opened={opened} onClose={onCancel}> <ModalBody> <strong> Are you sure? </strong> <div> <Button color='gray' onClick={onCancel}> Cancel </Button> <Button color='red' onClick={onConfirm}> Confirm </Button> </div> </ModalBody> </Modal> ); }

You could easily keep both the hook and the component inside one file, that's what I do in code anyway. I separated them here for a better clarity.

Alright, so how do you use this mechanism in action? Let's use it in the component from our first example:

items-list.tsx
interface Item { id: string; name: string; value: string; } function ItemsList({ items }: { items: Item[] }) { const { confirm, ...modalProps } = useConfirm(); return ( <> {items.map(item => ( <Item key={item.id} data={item} confirm={confirm} /> ))} <ConfirmModal {...modalProps} /> </> ) }

Isn't that just a little bit nicer than what we're used to? As you can see, the logic for removing the item is still left in the <Item /> component, but now, we pass the confirm function to it, instead of the openConfirmModal like before. Modal logic is now outsourced, leaving List component clean.

#Possible improvements

The current <ConfirmModal /> component is very generic, both in functionality and the layout. I intentionally left it that way, because I wanted a very simple and reusable solution. In practice, you may want to have some level of customizability. If so, I would propose to go to useConfirm hook and create an additional config state, that would be set from an argument passed to a confirm function. This config would be returned by the hook, and you could pass it down to your custom modal component using restProps (the same way I pass modalProps in the example above). The custom modal component could be passed to a <ConfirmModal /> as children.

export default function useConfirm<ModalParams extends Record<string, any>>() { ... const [modalParams, setModalParams] = useState<ModalParams>(); const confirm = (params?: ModalParams) => new Promise<boolean>((resolve) => { setResolvePrompt(() => resolve); setModalParams(params); open(); }); ... return { ..., modalParams, ... } } function MyFancyList() { const { confirm, modalParams, ...modalProps } = useConfirm(); return ( <> {...itemsComponent} <ConfirmModal {...modalProps}> <FancyListModalLayout {...modalParams} /> </ConfirmModal> </> ); }

The one thing that I don't like about the current implementation, is how the modal closes in the exact moment of clicking a button. It's not an error, but if you don't have optimistic updates implemented, your user will see the item, that he just deleted, still on the list, until the API call returns a response and the UI re-renders with a new state. To improve that behavior, you can either utilize the optimistic updates pattern, or modify the useConfirm hook, so that it will also await the actual removal, display loader on the buttons and handle the success and fail of the action.

I think that's well enough coding for now, though. Hope you find this solution inspiring!

Related tags:

modal
dialog
mechanism
hooks
async
react
list
removal
delete
crud