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

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

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

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

コールバックをpropsで渡す方法でも実装できますが、確認前後の処理が別の関数へ分かれやすくなります。Promiseを使う目的は、確認結果を通常の非同期処理と同じ制御フローで扱うことです。一方、未解決のPromiseや多重表示を生まないよう、キャンセル、アンマウント、表示中の再呼び出しについて方針を決める必要があります。

ここではReactとTypeScriptを使用し、ダイアログ本体には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-labelledbyとaria-describedbyで、ダイアログのタイトルと説明を関連付けています。HTML標準のダイアログのフォーカス動作については、MDNのdialog要素の説明を参照してください。

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

動作を確認する

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

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

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

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


関連記事

  • TypeScript i18nextの動的な翻訳キーを抽出対象に含める

    i18nextでステータスに応じた文言を表示する場合、翻訳キーを動的に切り替えることがあります。ただし、実行時に正しく表示できることと、翻訳キーの抽出ツールが使用箇所を検出できることは別です。今回は、...


  • React useStateで入力フォームを作成する

    Reactでテキストボックスに入力した値を使用するには、useStateで値を保持します。今回は名前とメールアドレスを入力し、送信ボタンを押すと入力内容を表示するフォームを作成します。この例では入力値...


  • React useStateで一覧の追加と削除を行う

    Reactで一覧を表示するときは、配列をuseStateへ保存できます。今回は簡単な買い物リストを作り、項目の追加と削除を行います。配列のpushやspliceで既存のstateを直接変更するのではな...


  • React useRefで入力欄にフォーカスを当てる

    入力フォームを開いた直後や、入力エラーが出たときに、特定の入力欄へフォーカスしたいことがあります。DOMの要素自体を参照するにはuseRefを使います。currentは初回描画中などにnullになり得...


  • React useMemoで重い計算結果を再利用する

    一覧の絞り込みや並べ替えを描画のたびに行うと、データ量や計算内容によっては画面操作が遅くなります。useMemoは、依存する値が変わらない間、以前の計算結果を再利用します。productsまたはque...


  • React useEffectで無限ループが発生するときに確認すること

    ReactのuseEffectを利用したときに無限ループが発生してしまうことがあります。特に注意したいのが、ESLintのreact-hooks/exhaustive-depsで表示された警告をUpd...


  • React useEffectの後片付けでイベント登録を解除する

    画面の幅が変わったときに表示を更新したい場合、ブラウザのresizeイベントを登録できます。登録したままにすると、コンポーネントが不要になったあとも処理が残るため、useEffectの後片付けを用意し...


  • React 関数コンポーネントにプロパティを設定する

    React+TypeScriptで作成した関数コンポーネントにプロパティを設定する方法を紹介します。以下は送信ボタンのコンポーネントを作成しています。isSubmittingというプロパティを用意して...


  • Reactで兄弟コンポーネントの状態を共有する

    検索欄に入力した文字列を、別の一覧コンポーネントでも使いたい場合があります。兄弟同士で別々にstateを持つと値がずれるため、共通の親にstateを置きます。入力欄は値を受け取り、変更を親へ通知します...


  • React useStateでselectの選択値を取得する

    Reactでプルダウンの選択値を扱うには、selectのvalueとonChangeを使用します。DOMから取得するvalueは、数値の選択肢を表示していても文字列です。選択値をそのまま識別子として扱...