iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🗨️

[React] Creating a Readable Confirmation Dialog with a window.confirm-like API

に公開
3

Introduction

Have you ever used the browser-standard window.confirm?
window.confirm
It is the simplest way to display a confirmation dialog.
The return value is a boolean type, returning true if the OK button is pressed and false if the Cancel button is pressed.
When expressed as a custom hook, it looks something like this:

**/components/delete-button.tsx
const useConfirm = (message: string, onConfirm: () => void) =>
  useCallback(() => {
    const result = window.confirm(message);
    if (result) onConfirm();
  }, [message, onConfirm]);

export const DeleteButton: FC = () => {
  const handleClick = useConfirm("Are you sure you want to delete?", () => console.log("Executing deletion process"));
  return <button onClick={confirm}>Delete</button>;
};

What makes this API excellent is that it handles the dialog's appearance and the management of its open/close state internally, allowing you to focus solely on what you want to do when "Yes" is selected and what message should be displayed.

In this way, if we can create an API that is easy to use without the implementer having to worry about the dialog's appearance or behavior, development efficiency will improve.
Could we achieve this same API with Dialog-based components in UI frameworks?

**/components/delete-button.tsx
import { ConfirmDialog } from "@/ui/confirm";
import { Button } from "@yamada-ui/react";
import { type ComponentRef, type FC, useCallback, useRef } from "react";

export const DeleteButton: FC = () => {
  const ref = useRef<ComponentRef<typeof ConfirmDialog>>(null);

  const handleClick = useCallback(async () => {
    const result = await ref.current?.confirm();
    if (result) {
      console.log("Executing deletion process");
    }
  }, []);

  return (
    <>
      <Button onClick={handleClick}>Delete</Button>
      <ConfirmDialog ref={ref}>Are you sure you want to delete?</ConfirmDialog>
    </>
  );
};

You can achieve an API like this using Promise and useImperativeHandle!

Promise with useImperativeHandle

Common ways to use dialogs
**/components/delete-button.tsx
import { Button, Dialog, useDisclosure } from "@yamada-ui/react";
import { type FC, useCallback } from "react";

export const DeleteButton: FC = () => {
  const { isOpen, onOpen, onClose } = useDisclosure();

  const handleSuccess = useCallback(() => {
    console.log("Executing deletion process");
    onClose();
  }, [onClose]);

  return (
    <>
      <Button onClick={onOpen}>Open</Button>
      <Dialog
        isOpen={isOpen}
        onClose={onClose}
        cancel={<Button onClick={onClose}>Cancel</Button>}
        success={
          <Button bg={"blue.500"} onClick={handleSuccess}>
            OK
          </Button>
        }
      >
        Are you sure you want to delete?
      </Dialog>
    </>
  );
};

Even though the component name is a button, a mysterious open/close state is being exposed via useDisclosure.
You have to wire up the dialog's open/close state every time you create a similar component.
In this example, no logic is written and the dialog content is just one line, but in actual development, it may include complex logic or multiple components.
A dialog might even contain another dialog.
Since a dialog and the state used for it should always be used as a set, it is desirable to create an API where they can be imported together.
By hiding common and repetitive processes, it becomes clear what you actually want to achieve.

What do you think?
I made the component expose only the confirm function to the parent component, while managing the dialog state internally.
Just like window.confirm, you can now focus on the expected process when the delete button is pressed and the content you want to display inside the dialog.

Implementation

I will introduce the internal implementation.

  • Component dependencies

State Management

ui/confirm/hooks.ts
import { useCallback, useState } from "react";

/** State type definition */
type State = {
  isOpen: boolean;
  resolve: (isSuccess: boolean) => void;
};

/** Initial state */
const initialState: State = {
  isOpen: false,
  resolve: () => {},
};

/** Custom hook to manage the open/close state of the confirmation dialog */
export const useConfirmState = () => {
  const [{ isOpen, resolve }, setState] = useState<State>(initialState);

  const confirm = useCallback(
    () =>
      new Promise<boolean>((resolve) => {
        setState({ isOpen: true, resolve });
      }),
    [],
  );

  const handleSuccess = useCallback(() => {
    resolve(true);
    setState(initialState);
  }, [resolve]);

  const handleCancel = useCallback(() => {
    resolve(false);
    setState(initialState);
  }, [resolve]);

  return {
    /** Dialog open/close state */
    isOpen,
    /** Async function that opens the dialog and waits for the user's confirmation result */
    confirm,
    /** Logic when the OK button is pressed */
    handleSuccess,
    /** Logic when the Cancel button is pressed */
    handleCancel,
  };
};

This is a custom hook that manages the state used within the dialog component. Let's visualize the flow of this hook.

  1. The initial state is set upon component mounting. isOpen is set to false for the closed state, and resolve is set to an empty function since it doesn't exist yet.
  2. The button is pressed, and the confirm function is called. To display the dialog, isOpen is changed to true and the resolve function is set. Since await confirm() waits until this resolve is called, the process does not proceed until the dialog is closed.
  3. One of the dialog buttons is pressed. At this point, the resolve function held in the state is called. The argument passed here is returned as the result of await confirm(), resolving the Promise. Finally, to close the dialog, the state is reset to the initial state using setState(initialState).

By expressing the state until the user presses a button and which button was pressed through Promise resolution, we have achieved an API like window.confirm that waits for user interaction!

Integrating with a Component Library

ui/confirm/index.tsx
import { Button, Dialog } from "@yamada-ui/react";
import { useImperativeHandle, forwardRef, type ComponentProps } from "react";
import { useConfirmState } from "./hooks";

/** Functions exposed to the parent component */
type Handle = { confirm: () => Promise<boolean> };
/** Props passed from the parent component */
type Props = Omit<ComponentProps<typeof Dialog>, "isOpen" | "onClose" | "cancel" | "success">;

export const ConfirmDialog = forwardRef<Handle, Props>((props, ref) => {
  const { isOpen, confirm, handleSuccess, handleCancel } = useConfirmState();
  useImperativeHandle(ref, () => ({ confirm }), [confirm]);

  return (
    <Dialog
      onClose={handleCancel}
      cancel={<Button onClick={handleCancel}>Cancel</Button>}
      success={
        <Button bg={"blue.500"} onClick={handleSuccess}>
          OK
        </Button>
      }
      {...{isOpen, ...props}}
    />
  );
});

By encapsulating the complex state management, integrating it into the component has become straightforward.
I used forwardRef to allow the parent to pass a ref.
While the type definition for forwardRef can be complex, as shown above, the first type argument is the type of the methods being exposed, and the second type argument is the type of the Props to be passed by the parent component.
By using useImperativeHandle, the parent component can call methods of the child component via useRef.
While useRef is often used to call methods of DOM components, you can also expose methods in custom components this way.
The first argument of useImperativeHandle is the ref passed from the parent, the second is a function that returns an object containing the methods to be exposed, and the third is the dependency array, similar to useEffect or other memoization hooks.
As of May 2024, React 19 (currently in beta) will allow passing ref directly without using forwardRef, which will make this even easier to use.

Advanced

Problems with the Render Hooks Pattern

While writing this article, I came across several other articles that suggested using the "Render Hooks" pattern.
I have listed them at the end for your reference.
However, the Render Hooks pattern has a problem: if the return value is a functional component (React.FC), unnecessary component unmounting occurs during state changes.
Since the component unmounts immediately when the state changes, if there are animations, the dialog will close instantly without waiting for the animation to play out.

https://www.asobou.co.jp/blog/web/reactfc-renderhooks

The details of this problem are explained in the article above. The author solves it by defining the component statically and injecting Props returned separately into the consumer side.
The downside of this method is that the consumer still needs to wire up the state, and it allows the consumer to access the state unnecessarily.
Also, if you use a variable component (JSX.Element), unmounting won't occur, but then the consumer cannot pass Props like children to the component.
In our use case, we need the consumer to decide the content to display, so this method is also not ideal.

Therefore, I came up with the approach using useImperativeHandle.
Initially, I planned to implement this using the Render Hooks pattern, but I noticed this issue during implementation and rewrote it.
It's a shame it can't be used declaratively, but losing animations is a critical issue.
For reference, I've shown the implementation using the Render Hooks pattern below, as seen in the original articles.

Implementation with the Render Hooks pattern
ui/confirm/index.tsx
import { Button, Dialog as Component } from "@yamada-ui/react";
import { useCallback, useMemo, type FC, type ComponentProps } from "react";
import { useConfirmState } from "./hooks";

export const useConfirm = () => {
  const { isOpen, confirm, handleOk, handleCancel } = useConfirmState();

  type Props = Omit<ComponentProps<typeof Component>, "isOpen" | "onClose" | "cancel" | "success">

  const Dialog: FC<Props> = 
    useCallback(
      (props) => (
        <Component
          onClose={handleCancel}
          cancel={<Button onClick={handleCancel}>キャンセル</Button>}
          success={
            <Button bg={"blue.500"} onClick={handleSuccess}>
              OK
            </Button>
          }
          {...{isOpen, ...props}}
        />
      ),
      [isOpen, handleCancel, handleSuccess],
    );
  return {
    Dialog,
    confirm,
  };
};
**/components/delete-button.tsx
import { useConfirm } from "@/ui/confirm";
import { Button } from "@yamada-ui/react";
import { type FC, useCallback } from "react";

export const DeleteButton: FC = () => {
  const { Dialog, confirm } = useConfirm();

  const handleClick = useCallback(async () => {
    const result = await confirm();
    if (result) {
      console.log("Executing deletion process");
    }
  }, [confirm]);

  return (
    <>
      <Button onClick={handleClick}>Delete</Button>
      <Dialog>Are you sure you want to delete?</Dialog>
    </>
  );
};

If there are no animations, the Render Hooks pattern works correctly, but it is generally rare for component libraries to lack animations. It is unnatural for the user to see an entry effect but have the dialog disappear abruptly upon closing. Furthermore, unnecessary component unmounting is undesirable from a performance perspective.

How to prevent unnecessary re-renders when the dialog is hidden

Using a Context within the dialog content causes re-renders to occur even when the dialog is hidden. To prevent this, it is effective to separate the displayed content into its own component and use dynamic imports.

delete-button.tsx
import { ConfirmDialog } from "@/ui/confirm";
import { Button } from "@yamada-ui/react";
import { type ComponentRef, type FC, lazy, useCallback, useRef } from "react";

export const DeleteButton: FC = () => {
  const ref = useRef<ComponentRef<typeof ConfirmDialog>>(null);

  const handleClick = useCallback(async () => {
    if (await ref.current?.confirm()) {
      console.log("Executing deletion process");
    }
  }, []);

  const ItemInformation = lazy(() => import("./ItemInformation"));

  return (
    <>
      <Button onClick={handleClick}>Delete</Button>
      <ConfirmDialog ref={ref}>
        <ItemInformation />
      </ConfirmDialog>
    </>
  );
};

Summary

By encapsulating the dialog's state management, we were able to create an API that allows focusing on the dialog's appearance and behavior. Initially, I planned to write this as an article promoting the Render Hooks pattern, but while implementing it, I discovered the issue where animations are lost and instead proposed the method using useImperativeHandle. This hook itself seems to be relatively unknown, so please give it a try.

References

  • Article by the (likely) originator

https://medium.com/@kch062522/useconfirm-a-custom-react-hook-to-prompt-confirmation-before-action-f4cb746ebd4e

  • Detailed explanation article

https://qiita.com/Yametaro/items/b6e035fe06530a9f47bc

  • Article explaining the significance of using Promise from the perspective of cohesion and coupling

https://zenn.dev/yumemi_inc/articles/f6ec17edf13670

GitHubで編集を提案

Discussion

nap5nap5

UIフレームワークのDialog系コンポーネントでもこのAPIを実現できないでしょうか?

react-callライブラリでも似た構造化は達成できそうでした

じょうげんじょうげん

これは面白いライブラリですね!
コンポーネントを呼び出し可能にするというメンタルモデルはわかりやすいです。
refを使わなくて良いのもシンプルで良い。