Kitsune Blog

PromiseとuseSyncExternalStoreで自作confirm/alertを作成する

PromiseとuseSyncExternalStoreで自作confirm/alertを作成しました。

UIコンポーネントはShadcnのAlertDialogを利用しています。

該当のコードはこちらです。

'use client';

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { AlertDialogDescription } from '@radix-ui/react-alert-dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
import { useSyncExternalStore } from 'react';

type DialogState = {
  open: boolean;
  title?: string;
  type?: 'alert' | 'confirm';
  resolve?: (value?: unknown) => void;
};

const defaultDialogState: DialogState = { open: false };

let dialogState: DialogState = defaultDialogState;
let listeners: (() => void)[] = [];

const dialogStore = {
  subscribe: (listener: () => void) => {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  },
  getSnapshot: () => dialogState,
  getServerSnapshot: () => defaultDialogState,
  setDialogState: (_dialogState: DialogState) => {
    dialogState = _dialogState;
    emitChange();
  },
};

const emitChange = () => {
  for (const listener of listeners) {
    listener();
  }
};

export const xAlert = (title: string) => {
  dialogStore.setDialogState({ open: true, title, type: 'alert' });
};

export const xConfirm = (title: string) => {
  return new Promise(resolve => {
    dialogStore.setDialogState({ open: true, title, type: 'confirm', resolve: resolve });
  });
};

export const AlertOrConfirmDialog = () => {
  const { open, title, resolve, type } = useSyncExternalStore(
    dialogStore.subscribe,
    dialogStore.getSnapshot,
    dialogStore.getServerSnapshot
  );

  const handleOk = () => {
    resolve?.(true);
    dialogStore.setDialogState({ open: false, title, resolve, type });
    setTimeout(() => {
      dialogStore.setDialogState(defaultDialogState);
    }, 300);
  };

  const handleCancel = () => {
    resolve?.(false);
    dialogStore.setDialogState({ open: false, title, resolve, type });
    setTimeout(() => {
      dialogStore.setDialogState(defaultDialogState);
    }, 300);
  };

  return (
    <AlertDialog open={open} onOpenChange={open => !open && handleCancel()}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <VisuallyHidden.Root>
            <AlertDialogDescription>確認内容を表示しています。</AlertDialogDescription>
          </VisuallyHidden.Root>
        </AlertDialogHeader>
        <AlertDialogFooter>
          {type === 'confirm' && (
            <AlertDialogCancel onClick={handleCancel}>キャンセル</AlertDialogCancel>
          )}
          <AlertDialogAction onClick={handleOk} autoFocus>
            {type === 'confirm' && 'OK'}
            {type === 'alert' && '閉じる'}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
};

AlertOrConfirmDialogコンポーネントはNext.jsの場合はlayout.tsx、vite利用でシンプルなReactの場合はApp.tsxでしょうか、アプリケーション全体で使用できる箇所に配置します。

Dialogの開閉状態や、内部のテキスト情報をコンポーネントの外部の変数dialogStateに格納し、

それをuseSyncExternalStoreで購読しています。

useSyncExternalStoreによる外部ストアの購読方法はこちらにサンプルがあります。 https://ja.react.dev/reference/react/useSyncExternalStore#subscribing-to-an-external-store

外部変数で状態を管理することで呼び出すときには

import { xAlert } from "@/components/AlertOrConfirmDialog";

try {
  // 目的の処理
} catch (error) {
  console.error(error);
  xAlert("エラーが発生しました。");
}

のようにimportのみ行っておけば、カスタムフックスを挟んだりせず、簡易に呼び出せます。

useStateやそのほかグローバルな状態管理ライブラリに依存している場合は、

どうしても呼び出し時にカスタムフックスが必要になります。

またconfirmに関してはPromiseを用いて、

OKボタンを押下した場合に後続の処理を行うように実装しています。

export const xConfirm = (title: string) => {
  return new Promise(resolve => {
    dialogStore.setDialogState({ open: true, title, type: 'confirm', resolve: resolve });
  });
}; 
 const handleOk = () => {
    resolve?.(true);
    dialogStore.setDialogState({ open: false, title, resolve, type });
    setTimeout(() => {
      dialogStore.setDialogState(defaultDialogState);
    }, 300);
  };

 const handleCancel = () => {
    resolve?.(false);
    dialogStore.setDialogState({ open: false, title, resolve, type });
    setTimeout(() => {
      dialogStore.setDialogState(defaultDialogState);
    }, 300);
  }; 

Dialog内のOKボタンやキャンセルボタン押下時に、Promiseが解決するようにし、戻り値のbooleanをもとに後続の処理を行うか判定しています。

呼び出しは簡単で下記にように使用します。

import { xConfirm } from "@/components/AlertOrConfirmDialog";

const onClick = async () => {
  if (await xConfirm("処理を行いますか?")) {
    // 後続の処理
  }
};

以上が、自作confirm/alertの作成方法となります。

上記コードでは、Dialogを閉じる時のUIのがたつき防止のためにsetTimeoutを使用していたり、

ShadcnのAlertDialogではDescriptionに該当するものがない時にアクセシビリティのエラーが出るため、その対応のためのコードも入っています。

この辺りはモーダルコンポーネントの作成に利用するUIコンポーネントの事情によるところでもあり、微調整が必要になるところかと思います。