JMDC TECH BLOG

JMDCのエンジニアブログです

React で dialog を利用する in 2024

この記事は JMDC Advent Calendar 2024 20日目の記事です。

qiita.com

まえがき

プロダクト開発部に所属する八杉です。フロントエンドのイネイブリングチームとして社内のさまざまなプロダクトのフロントエンド開発をサポートする仕事をしています。今年取り組んだ仕事の中から、この記事では <dialog> 要素を React のプロダクトに取り入れた話を紹介します。実装の要点や <dialog> を利用する上での注意点を解説していますので、参考にしていただけたら幸いです。ただし、あくまで 2024 年現在の情報に基づいた内容になっていますから、その点はご留意ください。

目次

  • 導入
  • 対象環境
  • 今回検証したもの
  • 解説
    1. 手続き的な API で開閉する
    2. 背景のスクロールを無効化する
    3. Portal を利用してレンダーする
    4. ダイアログが開くまで子要素のマウントを遅延する
  • <dialog> を利用する上での注意点
    1. UI ライブラリが Top Layer に対応していない
    2. jsdom が <dialog> に対応していない
  • まとめ

導入

対象のプロダクトでは、もともとモーダルダイアログ (以降、単にダイアログと書きます) の実装に React Aria を利用していました。ダイアログといえば、かつては実装が難しい UI の代表例でした。しかし、現代のブラウザ環境では <dialog> が利用できますから、ライブラリに頼らずともアクセシブルなダイアログ UI を実装することは難しくないはずです。そのプロダクトでは React Aria をダイアログの実装以外に利用していませんでしたから、もし置き換えることができれば React Aria への依存を無くせます。外部ライブラリへの依存が減れば更新に追従する手間が省けますし、バンドルサイズや CI でのパッケージのインストール時間の削減にも繋がりますから、メリットは大きいです。このような背景から、 <dialog> を React で利用する方法を検証してみることにしました。

対象環境

まず前提となる対象環境を記しておきます。このプロダクトは社内向けの管理ツールで、 React Router 製の SPA です。Chrome/Edge の最新版を対象としており、 <dialog> が利用できます。また、検証に使用した React のバージョンは 19.0.0 です。

今回検証したもの

いきなりですが、要点を抜粋したものを以下に記載します。CSS など一部のコードは割愛しています。

// Dialog.tsx
import type { ReactNode, RefObject } from 'react'
import {
  useCallback,
  useId,
  useImperativeHandle,
  useRef,
  useSyncExternalStore,
} from 'react'
import { createPortal } from 'react-dom'

type Props = {
  children: ReactNode
  ref: RefObject<DialogRef | null>
}

export type DialogRef = {
  showModal: () => void
  close: () => void
}

export const Dialog = (props: Props) => {
  const { children, ref } = props

  const id = useId()
  const internalRef = useRef<HTMLDialogElement>(null)

  useImperativeHandle(
    ref,
    () => ({
      showModal: () => internalRef.current?.showModal(),
      close: () => internalRef.current?.close(),
    }),
    [],
  )

  const isOpen = useSyncExternalStore(
    useCallback((onStoreChange) => {
      const observer = new MutationObserver(onStoreChange)

      if (internalRef.current) {
        observer.observe(internalRef.current, {
          attributes: true,
          attributeFilter: ['open'],
        })
      }

      return () => {
        observer.disconnect()
      }
    }, []),
    () => internalRef.current?.open ?? false,
  )

  return createPortal(
    <>
      <style
        href={id}
        precedence={'default'}
      >{`html:has(#${CSS.escape(id)}[open]) { overflow: hidden; }`}</style>
      <dialog ref={internalRef} id={id}>
        <form method={'dialog'} onSubmit={(event) => event.stopPropagation()}>
          <button aria-label={'閉じる'}>X</button>
        </form>
        {isOpen && children}
      </dialog>
    </>,
    document.body,
  )
}
// useDialog.tsx
import { useCallback, useRef } from 'react'
import type { DialogRef } from './Dialog'

export const useDialog = () => {
  const ref = useRef<DialogRef>(null)

  const showModal = useCallback(() => {
    ref.current?.showModal()
  }, [])

  const close = useCallback(() => {
    ref.current?.close()
  }, [])

  return {
    ref,
    showModal,
    close,
  }
}

これを以下のようにして使います。

const Component = () => {
  const { ref, showModal, close } = useDialog()

  return (
    <div>
      <button type={'button'} onClick={showModal}>開く</button>
      <Dialog ref={ref}>
        <button type={'button'} onClick={close}>キャンセル</div>
      </Dialog>
    </div>
  )
}

解説

今回作成したダイアログは次のようなものです。それぞれの要件ごとに解説していきます。

  1. 手続き的な API で開閉する
  2. 背景のスクロールを無効化する
  3. Portal を利用してレンダーする
  4. ダイアログが開くまで子要素のマウントを遅延する

1. 手続き的な API で開閉する

Dialog コンポーネントの実装にあたり、 isOpen のような真偽値 prop による宣言的な開閉処理はサポートしないことにしました。

// このような API はサポートしませんでした。
<Dialog isOpen={isOpen} onClose={handleClose}></Dialog>

代わりに、 showModal()close() による手続き的な開閉のみを扱うことにしました。

const { ref, showModal, close } = useDialog()

return (
  <div>
    <button type={'button'} onClick={showModal}>開く</button>
    <Dialog ref={ref}>
      <button type={'button'} onClick={close}>キャンセル</div>
    </Dialog>
  </div>
)

少し背景を補足すると、 React の UI ライブラリの多くは openisOpen といった真偽値 prop によるダイアログの開閉をサポートしています。

// mui
<Dialog open={open} onClose={handleClose}>
// radix-ui
<Dialog.Root open={open} onOpenChange={hadnleOpenChange}>
// react-aria-components
<DialogTrigger isOpen={open} onOpenChange={handleOpenChange}>

当初はそれにならってコンポーネントを設計しようとしたのですが、一筋縄ではいかないことに気がつきました。<dialog> には開閉状態を表す open 属性が存在しますが、これをつけ外しするだけではモーダルダイアログとして開くことはできません。モーダルダイアログとして表示するには必ず showModal() メソッドを呼ぶ必要があるのです。

<dialog> が宣言的な属性による開閉をサポートしていない以上、真偽値 prop による開閉を行う方法は 2 通り考えられます。ひとつは useEffect() を使う方法です。

const Dialog = ({ isOpen }: { isOpen: boolean }) => {
  // isOpen の変化に反応してダイアログを開閉する
  useEffect(() => {
    if (isOpen) {
      dialogRef.current?.showModal()
    } else {
      dialogRef.current?.close()
    }
  }, [isOpen])
  
  // ...
}

もうひとつは ref callback を使う方法です。

const Dialog = ({ isOpen }: { isOpen: boolean }) => {
  const ref = useCallback((node) => {
    node?.showModal()
  }, [])

  return isOpen && <dialog ref={ref}>...</dialog>
}

しかし、結果的にはどちらの方法も採用しませんでした。<dialog> は Escape キーを押して閉じることができます。これにより、 isOpentrue なのに <dialog> は閉じているという状況が起こりえます。このような食い違いはバグの元です。問題の原因は、ダイアログの開閉状態を DOM の外側で管理しようとしたことにあります。DOM が宣言的な API を提供していない以上、 DOM の状態を真実とみなすべきであると私たちは考えました。Single source of truth の考え方に従い、今回は React 側での状態管理は行わないことにしました。

2. 背景のスクロールを無効化する

ダイアログを表示している間は背景のスクロールを無効化したいです。実は <dialog> にはこのような機能はありません。HTML の仕様としてこの挙動を取り入れようという動きも見られますが、今のところは思い思いの方法で対応されているようです。

github.com

<body> ないし <html>overflow: hidden; を付与する方法がよく採用されているようでしたので、今回はそれにならうことにします。これを次のように実装しました。

const id = useId()

return (
  <>
    <style>{`html:has(#${CSS.escape(id)}[open]) { overflow: hidden; }`}</style>
    <dialog id={id}>
      ...
    </dialog>
  </>
)

ポイント

  1. ダイアログごとに id を発行して has: セレクタで絞り込みます。<style> はグローバルに作用しますが、セレクタで絞り込むことで影響範囲を特定のコンポーネントインスタンスに限定しています。
  2. CSS.escape() メソッドを使って id をエスケープします。React の id は :r1: のように両端にコロンを含む文字列です。そのままでは CSS のセレクタとして利用できませんから、エスケープして利用します。

また、この方法にはダイアログの開閉時に背景が左右にレイアウトシフトしてしまう欠点があることも知られています。overflow: hidden; の有無によってスクロールバーが出没するためです。これに対しては背景を blur させることで視覚的なストレスを緩和する策を講じました。

// Tailwind CSS の場合のコード例
<dialog className='backdrop:bg-black/50 backdrop:backdrop-blur'>
  ...
</dialog>

なお、厳密にいうと <style><body> の配下に記載することは HTML の仕様に反します。これが気になる場合は React 19 の stylesheet support を利用するとよいでしょう。hrefprecedence を指定することでこのスタイルは <head> に挿入されるようになり、違反が解消します。

<style
  href={id}
  precedence={'default'}
>{`html:has(#${CSS.escape(id)}[open]) { overflow: hidden; }`}</style>

react.dev

3. Portal を利用してレンダーする

ユーザがダイアログの閉じ方で迷わないように、ダイアログの右上に常に"閉じる"ボタンを表示しておくことにします。<form method="dialog"> を利用すると JS のコードを書かずに"閉じる"ボタンを実現できます。

// Dialog.tsx
return (
  <dialog>
    <form method={'dialog'}>
      <button aria-label={'閉じる'}>X</button>
    </form>
  </dialog>
)

ただし、この方法には注意点があります。この Dialog コンポーネントをフォームの中に配置すると <form> が入れ子になってしまうのです。

<form method={'post'}>
  <Dialog />
</form>

// ↓ render

// <form> がネストしている
<form method="post">
  <dialog>
    <form method="dialog">
      <button aria-label="閉じる">X</button>
    </form>
  </dialog>
</form>

このマークアップは HTML の仕様に反しますから、想定外の挙動を招くおそれがあります。

仮に"閉じる"ボタンの実装に <form> を用いなかったとしても、ダイアログ内のコンテンツとしてフォームを表示することは考えられます。そのため、こういったネスト構造の問題に悩まされないよう、ダイアログを描画する際は Portal を利用するのが安全でしょう。

// Dialog.tsx
return (
  createPortal(
    <dialog>
      <form method={'dialog'}>
        <button aria-label={'閉じる'}>X</button>
      </form>
    </dialog>,
    document.body
  )
)
<form method={'post'}>
  <Dialog />
</form>

// ↓ render

// <form> のネストが解消した
<body>
  <form method="post"></form>
  <dialog>
    <form method="dialog">
      <button aria-label="閉じる">X</button>
    </form>
  </dialog>
</body>

これで DOM ツリーの上では <form> のネストが解消しますが、実はさらに注意点があります。createPortal には、 Portal の内部で発生したイベントが DOM ツリーではなく React ツリーにしたがって伝播する特徴があります。

react.dev

Events from portals propagate according to the React tree rather than the DOM tree. For example, if you click inside a portal, and the portal is wrapped in <div onClick>, that onClick handler will fire. If this causes issues, either stop the event propagation from inside the portal, or move the portal itself up in the React tree.

そのため、"閉じる"ボタンをクリックした際に発生した submit イベントは Dialog コンポーネントの外側の <form> にまで伝播します。外側のフォームが誤送信されることを防ぐため、"閉じる"ボタンで発生した submit イベントの伝播を止める対応を行います。

// Dialog.tsx
return (
  createPortal(
    <dialog>
      <form method={'dialog'} onSubmit={(event) => event.stopPropagation()}>
        <button aria-label={'閉じる'}>X</button>
      </form>
    </dialog>,
    document.body
  )
)

せっかく JS 無しで"閉じる"ボタンを実装できたと思ったのに結局 JS を書いているのがなんとも今ひとつです。<form method={'dialog'}> を使わずに実装するのも一つの手でしょう。ただし、ダイアログ内のコンテンツとして form を表示する場合には結局同様の対応が必要になりますから、注意が必要です。

<form method={'post'}>
  <Dialog>
    <form onSubmit={(event) => {
      event.stopPropagation()
    }>
      ...
    </form>
  </Dialog>
</form>

今回はポータルのレンダリング先を document.body に決め打ちしましたが、要件によっては差し替えられるようにしておくとよいでしょう。

4. ダイアログを開くまで子要素のマウントを遅延する

Dialog コンポーネントを次のように実装した場合、 Dialog をレンダーした時点で子要素もマウントされます。

const Dialog = ({ children }) => {
  return (
    <dialog>
      {children}
    </dialog>
  )
}

しかし、この挙動では困ることがあります。たとえば、ダイアログを開くまでデータフェッチの開始を遅延したいケースが挙げられます。

const App = () => {
  const { ref, showModal } = useDialog()
  
  return (
    <div>
      <button type={'button'} onClick={showModal}>開く</button>
      <Dialog ref={ref}>
        <Suspense fallback={'読み込み中...'}>
          <UserInfo id={user.id} />
        </Suspense>
      </Dialog>
    </div>
  )
}
const UserInfo = (props) => {
  const { data } = useUserInfo({ id: props.id })
  // ...
}

useUserInfo はサーバからユーザ情報をフェッチする処理だと思ってください。この場合、 App をレンダーした時点で UserInfo コンポーネントがマウントされてデータフェッチが始まってしまいます。たとえばユーザのリストに対するループの中でこのコンポーネントをレンダーしていた場合、同時に大量のリクエストが走ることになります。

こういったケースに対応できるよう、ダイアログを開くまで子要素のマウントを遅延させることにします。次のように開閉状態を表す state があれば実現できそうです。

const Dialog = ({ children }) => {
  const isOpen = getDialogState() // ???
  
  return (
    <dialog>
      {isOpen && children}
    </dialog>
  )
}

通常、 React では DOM の状態は state で管理され、 state の変更が DOM に同期されます。今回はその逆で、 <dialog> の状態を React 側に同期することになります。手続き的な API でダイアログを開閉することを選んだ以上、「今ダイアログは開いているか?」という真実を知っているのは DOM だけなのです。そこで、 MutationObserver を使って <dialog>open 属性の変更を監視して React に同期することにします。こういったケースでは React の useSyncExternalStore() フックが使えます。

const ref = useRef<HTMLDialogElement>(null)
const isOpen = useSyncExternalStore(
  useCallback((onStoreChange) => {
    const observer = new MutationObserver(onStoreChange)
    
    if (ref.current) {
      observer.observe(ref.current, {
        attributes: true,
        attributeFilter: ['open'],
      })
    
    
    return () => {
      observer.disconnect()
    }
  }, []),
  () => ref.current?.open ?? false,
)

return (
  <dialog ref={ref}>
    {isOpen && children}
  </dialog>
)

これで遅延マウントを実現できました。これは開閉時にダイアログの子要素の状態をリセットするのにも都合がよいです。なお、この記事では深入りしませんが、ダイアログを閉じる時にアニメーションをつけたい場合はさらに工夫が必要になります。

<dialog> を利用する上での注意点

最後に、 <dialog> を利用する上での注意点を記しておきます。

UI ライブラリが Top Layer に対応していない

それは、現在広く利用されている UI ライブラリの多くが Top Layer の仕様に対応していないことです。Top Layer というのは、 showModal() で表示した <dialog> や popover 要素など一部の要素だけが利用できる特権的な表示領域です。ここに配置された要素は他のすべての要素よりも上層に (手前に) 表示されます。つまり、 UI が Top Layer に対応していない場合、その UI をダイアログよりも上層に表示することはできません。たとえば、ダイアログの表示中にトーストメッセージを出そうとしても、トーストがダイアログよりも下層に表示されることになります。ダイアログの背景は暗くしたり blur させることが普通ですから、これではトーストの内容を読むことができません。

右下にトーストが表示されているが、内容を読むことができない

Popover APIを使ってトーストを実装すれば、ダイアログよりも上層に表示することはできます。しかし、他にもまだ課題が残されています。現在の HTML の仕様では、モーダルダイアログを表示している間はトーストがアクティブにならない (クリック等の操作を受けつけない) のです。これではトーストの中にボタンやリンクを配置している場合に操作できず困りますから、仕様の調整が議論されています。

github.com

一応、トーストを <dialog> の子要素として配置するという回避策が存在するため、私たちのチームではこの方法を取ることにしました。しかし、場合によっては対処するのが難しいこともありそうです。プロダクトに <dialog> を導入するかどうかは、こういった制約の存在を踏まえて慎重に判断する必要があります。

jsdom が <dialog> に対応していない

また、テスト手法については注意が必要で、この記事を執筆している時点で jsdom は <dialog> をサポートしていません。<dialog> を使ったコードをテストするには Vitest の Browser Mode 等を利用するとよいでしょう。

まとめ

この記事では、 <dialog> を React で実装する上での要点を解説してきました。ライブラリへの依存がなくなり、コードベースの保守性が向上したと感じています。私たちのチームでは、 useDialog() フックの形で利用する以外に、コンポーネントでラップして render prop の形にして利用することも多いです。どういう API にすると使いやすいかを考えてみるのもおもしろいですね。

ただ、 <dialog> の導入にはさまざまな障壁があることもわかりました。特に Top Layer にまつわる種々の課題が解決されない限り、移行はなかなか進まなそうだという印象を持ちました。現時点では <dialog> を活用できるアプリケーションは限られそうですが、要件が許すなら取り入れてみるのもおもしろいと思います。それでは。

明日21日目は、石井さんによる「Design Docで歴史を残そう」です。お楽しみに!

JMDCでは、ヘルスケア領域の課題解決に一緒に取り組んでいただける方を積極採用中です!フロントエンド /バックエンド/ データベースエンジニア等、様々なポジションで募集をしています。詳細は下記の募集一覧からご確認ください。 hrmos.co

まずはカジュアルにJMDCメンバーと話してみたい/経験が活かせそうなポジションの話を聞いてみたい等ございましたら、下記よりエントリーいただけますと幸いです。 hrmos.co

★最新記事のお知らせはぜひ X(Twitter)、またはBlueskyをご覧ください!

https://twitter.com/jmdc_tech

twitter.com

bsky.app