zukucode
主にWEB関連の情報を技術メモとして発信しています。

React 確認ダイアログの結果をPromiseで受け取る

Reactで確認ダイアログを表示し、ユーザーの選択をPromise<boolean>で受け取る方法を紹介します。

await showConfirm(...)と書けるようにすると、確認から実行までを1つの非同期関数にまとめられます。

ここではReactTypeScriptを使用し、ダイアログ本体にはHTML標準の<dialog>を使います。以下の3ファイルを同じディレクトリへ配置する構成です。

ContextとカスタムHookを作成する

まず、確認ダイアログを呼び出すためのContextを作成します。

DialogContext.ts
import { createContext, useContext } from 'react';

export type ConfirmOptions = {
    message: string;
    confirmLabel?: string;
};

type DialogApi = {
    showConfirm: (options: ConfirmOptions) => Promise<boolean>;
};

export const DialogContext = createContext<DialogApi | undefined>(undefined);

export const useDialog = () => {
    const context = useContext(DialogContext);
    if (context === undefined) {
        throw new Error('DialogProviderが必要です。');
    }
    return context;
};

showConfirmは確認された場合にtrue、キャンセルされた場合にfalseを返します。

confirmLabelでは、「削除」など具体的なボタン名を指定できます。

Providerを作成する

次に、状態の保持・ダイアログの表示・結果の返却をまとめたProviderを作成します。

以下がDialogProvider.tsxの全体です。Hookはすべてコンポーネント内で呼び出しています。

DialogProvider.tsx
import {
    useCallback,
    useEffect,
    useId,
    useMemo,
    useRef,
    useState,
    type ReactNode,
} from 'react';
import { DialogContext, type ConfirmOptions } from './DialogContext';

export const DialogProvider = ({ children }: { children: ReactNode }) => {
    const [options, setOptions] = useState<ConfirmOptions | null>(null);
    const dialogRef = useRef<HTMLDialogElement>(null);
    const resolveRef = useRef<((value: boolean) => void) | null>(null);
    const mountedRef = useRef(false);
    const titleId = useId();
    const messageId = useId();

    useEffect(() => {
        mountedRef.current = true;

        return () => {
            mountedRef.current = false;
            const resolve = resolveRef.current;
            resolveRef.current = null;
            resolve?.(false);
        };
    }, []);

    const showConfirm = useCallback((nextOptions: ConfirmOptions) => {
        // 表示中の要求を上書きせず、追加の要求はキャンセル扱いにする。
        if (!mountedRef.current || resolveRef.current !== null) {
            return Promise.resolve(false);
        }

        return new Promise<boolean>((resolve) => {
            resolveRef.current = resolve;
            setOptions(nextOptions);
        });
    }, []);

    const close = useCallback((result: boolean) => {
        const resolve = resolveRef.current;
        if (resolve === null) return;

        resolveRef.current = null;
        dialogRef.current?.close();
        setOptions(null);
        resolve(result);
    }, []);

    useEffect(() => {
        const dialog = dialogRef.current;
        if (dialog === null) return;

        if (options !== null && !dialog.open) {
            dialog.showModal();
        } else if (options === null && dialog.open) {
            dialog.close();
        }
    }, [options]);

    const value = useMemo(() => ({ showConfirm }), [showConfirm]);

    return (
        <DialogContext.Provider value={value}>
            {children}
            <dialog
                ref={dialogRef}
                aria-labelledby={titleId}
                aria-describedby={messageId}
                onCancel={(event) => {
                    event.preventDefault();
                    close(false);
                }}
            >
                <h2 id={titleId}>操作の確認</h2>
                <p id={messageId} style={{ whiteSpace: 'pre-line' }}>
                    {options?.message}
                </p>
                <button type="button" autoFocus onClick={() => close(false)}>
                    キャンセル
                </button>
                <button type="button" onClick={() => close(true)}>
                    {options?.confirmLabel ?? '実行'}
                </button>
            </dialog>
        </DialogContext.Provider>
    );
};

表示するメッセージはuseStateへ、Promiseを完了するための関数はuseRefへ保存しています。

showConfirmを呼び出した段階ではresolveを実行せず、ボタンが押されるまで待ちます。表示内容が更新されると、Effect内でshowModal()を呼び出します。

showModal()で開いたダイアログはモーダル表示となり、同じ文書内の背景要素を操作できなくなります。MDNのshowModal()の説明

<dialog open>を指定するだけでは、このモーダル表示にはなりません。

呼び出し側を作成する

以下は、確認された場合だけ一覧から項目を削除する例です。App.tsxを通常のReactアプリケーションのエントリーポイントから描画します。

App.tsx
import { useState } from 'react';
import { DialogProvider } from './DialogProvider';
import { useDialog } from './DialogContext';

const ShoppingList = () => {
    const { showConfirm } = useDialog();
    const [items, setItems] = useState(['りんご', '牛乳']);
    const [message, setMessage] = useState('');

    const handleDelete = async (name: string) => {
        const confirmed = await showConfirm({
            message: `「${name}」を一覧から削除しますか?`,
            confirmLabel: '削除',
        });

        if (!confirmed) return;

        setItems((current) => current.filter((item) => item !== name));
        setMessage(`「${name}」を削除しました。`);
    };

    return (
        <main>
            <h1>買い物リスト</h1>
            <ul>
                {items.map((item) => (
                    <li key={item}>
                        {item}
                        <button type="button" onClick={() => void handleDelete(item)}>
                            {item}を削除
                        </button>
                    </li>
                ))}
            </ul>
            <p role="status">{message}</p>
        </main>
    );
};

export default function App() {
    return (
        <DialogProvider>
            <ShoppingList />
        </DialogProvider>
    );
}

例では動作を確認しやすいように、Reactのstateだけを更新しています。サーバー上のデータを削除する場合は、確認後にAPIを呼び出し、成功時に一覧を更新します。APIの失敗時にはエラー表示も必要です。

useDialogを呼び出すコンポーネントは、必ずDialogProviderの子孫に配置します。

画面側のイベント処理をカスタムHookへ分ける方法については、以下の記事でも紹介しています。

Reactの複雑な画面をカスタムフックとViewに分離する

キャンセル時にもPromiseを完了する

close(true)なら確認、close(false)ならキャンセルとして、待機している処理へ結果を返します。

Escapeキーで閉じる操作も、onCancelからclose(false)につなげています。ブラウザ標準の終了処理をpreventDefault()で止め、ダイアログの終了とPromiseの完了を同じ関数で処理します。

dialog.close()やstateの更新だけでは、Promiseは完了しません。ダイアログを閉じる操作を追加する場合も、必ず結果を返す処理へ接続します。

この例では背景クリックで閉じる処理を追加していません。また、method="dialog"のフォームなど、close関数を通らず終了する経路を混在させないようにします。

Providerがアンマウントされた場合は、Effectのクリーンアップでfalseを返します。これにより、画面遷移などでダイアログが消えたあとも呼び出し側が待ち続ける状態を防ぎます。

この例のshowConfirmは、画面のボタンクリックなど、マウント後のユーザー操作から呼び出す想定です。

多重表示で前の要求を上書きしない

確認中にもう一度showConfirmを呼ぶと、1つの変数だけでresolveを管理する実装では前の要求を上書きしてしまう可能性があります。

今回の実装では、resolveRef.currentが残っている間、追加の要求へ即座にfalseを返します。先に表示したダイアログはそのまま残ります。

stateの再描画を待たずにRefへ保存するため、同じイベント内から続けて呼び出された場合も判定できます。

複数の要求を順番に表示したい用途では、キャンセル扱いにする代わりにキューで管理します。

フォーカスと表示内容を確認する

キャンセルボタンにautoFocusを指定し、表示直後に削除ボタンへフォーカスが当たらないようにしています。

また、aria-labelledbyaria-describedbyで、ダイアログのタイトルと説明を関連付けています。HTML標準のダイアログのフォーカス動作については、MDNのdialog要素の説明を参照してください。

削除後に元のボタンが消える画面では、次の項目や一覧の見出しなどへフォーカスを移す処理も、画面の構成に応じて追加します。

動作を確認する

以下の操作を確認します。

操作結果
一覧の削除ボタンを押す対象名を含む確認ダイアログが開く
ダイアログの削除ボタンを押すtrueが返り、対象が一覧から消える
キャンセルボタンを押すfalseが返り、一覧は変わらない
Escapeキーを押すキャンセルとして閉じ、一覧は変わらない
確認中にコードから追加の要求を呼ぶ追加の要求だけがfalseになる
確認中にProviderをアンマウントする待機中の要求がfalseになる

awaitはブラウザ全体を停止するものではありません。ダイアログを操作できる状態のまま、呼び出した関数の続きだけを待機させます。

確認ダイアログは、操作の影響に応じて使い分けます。通常の更新では成功通知だけにするなど、すべての操作へ確認を追加する必要はありません。


関連記事