logo

zustandで便利な確認ダイアログを作る

2024-10-30
a month ago

開発環境

  • next 14.2.15
  • zustand 5.0.0
  • @headlessui/react 2.2.0

前提

状態管理はzustand、ダイアログはheadlessUIを利用していますが、必須ではありません。

他のライブラリでも代用は可能です。

本題

よくあるデータ削除前の確認用の「本当に削除しますか?」という、あのダイアログを実装してみます。

要件としては以下です。

  • どこからでも簡単に利用できること
  • formのonSubmit関数のような送信処理の中で利用すること

1. 確認ダイアログのデザインと構造

まず、確認ダイアログのUIを構築します。以下のConfirmDialogコンポーネントでは、@headlessui/reactlucide-reactを利用し、ダイアログの開閉やボタンの設定を行います。

import { Fragment } from 'react';
import { Dialog, Transition, TransitionChild, DialogPanel } from '@headlessui/react';
import { XIcon } from 'lucide-react';
import { Button } from './button';

type ConfirmDialogProps = {
  title: string;
  message: string;
  close: (arg: boolean) => void;
};

export const ConfirmDialog = ({ title, message, close }: ConfirmDialogProps) => {
  return (
    <Transition appear show as={Fragment}>
      <Dialog as="div" className="relative z-40" onClose={() => null}>
        <TransitionChild as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100" leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0">
          <div className="bg-black/70 fixed inset-0" />
        </TransitionChild>

        <div className="fixed inset-0 overflow-y-auto">
          <div className="flex min-h-full items-center justify-center text-center">
            <TransitionChild as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100" leave="ease-in duration-200" leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95">
              <DialogPanel className="shadow-300 bg-white relative w-auto min-w-80 max-w-xs overflow-hidden rounded-2xl border px-5 py-7 text-left align-middle transition-all sm:min-w-96 sm:max-w-md">
                <div className="absolute right-1 top-1">
                  <Button size="icon" variant="ghost" onClick={() => close(false)}>
                    <XIcon />
                  </Button>
                </div>
                <div className="my-2 font-bold">{title}</div>
                <div className="mt-2">
                  <p className="whitespace-pre-wrap [overflow-wrap:anywhere]">
                    {message}
                  </p>
                </div>

                <div className="mt-5 flex justify-end gap-2">
                  <Button variant="secondary" onClick={() => close(false)}>いいえ</Button>
                  <Button onClick={() => close(true)}>はい</Button>
                </div>
              </DialogPanel>
            </TransitionChild>
          </div>
        </div>
      </Dialog>
    </Transition>
  );
};

2. 状態管理(zustand)によるダイアログ制御

続いて、zustandで確認ダイアログの開閉を管理します。以下のConfirmStoreで、開閉の状態や確認結果をPromiseで返すように設定しています。

import { create } from 'zustand';

export type ConfirmStore = {
  confirm?: {
    title: string;
    message: string;
    resolve: (arg: boolean) => void;
  };
  open: (confirm: { title: string; message: string }) => Promise<boolean>;
  close: (result: boolean) => void;
};

export const useConfirmStore = create<ConfirmStore>((set) => ({
  open: (confirm) =>
    new Promise((resolve) => {
      set((state) => ({
        ...state,
        confirm: {
          ...confirm,
          resolve,
        },
      }));
    }),
  close: (arg) => {
    set((state) => {
      state.confirm?.resolve(arg);
      return { ...state, confirm: undefined };
    });
  },
}));

ここでは、openでダイアログのメッセージやタイトルを設定し、Promiseで確認の結果を受け取れるようにしています。closeでは確認結果を返してダイアログを閉じることができます。

3. プロバイダーによるグローバル適用

ConfirmProviderを作成して、ダイアログコンポーネントがどの画面からでも使用できるように設定します。これにより、confirm状態が変わるとダイアログが自動的に表示されます。

'use client';

import type { ReactNode } from 'react';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmStore } from '@/stores/confirm';

export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
  const { confirm, close } = useConfirmStore();

  return (
    <>
      {children}
      {confirm && <ConfirmDialog {...confirm} close={close} />}
    </>
  );
};

confirmが存在する場合にConfirmDialogがレンダリングされるため、設定した状態に応じてダイアログが表示されます。

このプロバイダーはsrc/app/layout.tsxなどのファイルで利用することを想定しています。

4. 実際の使用例

最後に、確認ダイアログの利用例です。ここではボタンをクリックした際に確認ダイアログを表示し、ユーザーが「はい」をクリックするとconsole.logで処理が実行されます。

'use client';

import { Button } from '@/components/ui/button';
import { useConfirmStore } from '@/stores/confirm';

export const ConfirmButton = () => {
  const { open } = useConfirmStore();
  
  return (
    <Button
      onClick={async () => {
        if (await open({ title: '確認', message: '本当に削除しますか?' })) {
          console.log('---submit---');
        }
      }}
    >
      削除
    </Button>
  );
};

この例では、ボタンをクリックするとopenメソッドが呼ばれ、ダイアログが表示されます。「はい」または「いいえ」を選択することで、結果がPromiseで返され、後続処理に進むことができます。

「はい」をクリックしたときだけ、コンソールログが出力されるはずです。

さいごに

今回紹介した方法で、確認ダイアログを簡単に実装し、どのコンポーネントからでも柔軟に使用できるようになります。

最後の使用例では分かりやすくするために単純なボタンコンポーネントをクリックした挙動を紹介しましたが、formのonSubmit関数のような送信処理の中で利用することももちろん可能です。

参照