フューチャー技術ブログ

ダイアログもアラートも、Reactで子コンポーネントの開閉管理を実装する

Reactでは、画面に関わる表示の制御はかならず何かしらのステート管理を行いそれで行います。ダイアログの場合は開閉をuseState()で作ったフラグで管理するみたいな感じです。

たとえば、ウェブブラウザのJavaScriptから呼べるalert()confirm()は、関数を呼び出せばダイアログが表示されますし、ダイアログが閉じたら処理が戻ってきます。confirm()ならユーザーが選択したものと一緒に返ってきます。標準の<dialog>タグが今時ですが、このタグはDOMインスタンスのshowModal()show()メソッドを呼ぶ必要があります。命令志向ですね。

一方、Reactでダイアログを実装する場合を考えます。メソッド呼び出しが直接扱えればシンプルですが、Reactでは基本的にステート管理でやりましょう、というのが流儀です。useImperativeHandle()を使うとか、forwardRef()を使うとか、いろいろ手はありますが、できることならrefは複雑化して利用者が動きを理解するのが難しくなりがちなので、呼び元でrefを使わなくても良い方法を考えました。

親と子のコンポーネントの関心を考える

親コンポーネントの立場で見れば、関心があることは次の2つです。

  • ダイアログを開く
  • ダイアログが閉じられた時に結果を受け取る

この操作のためにrefで子コンポーネントの参照を取得するとか、ダイアログの開閉状態の管理をする、というのは本来やりたくない仕事のはずです。

一方で子供の方を見て見ましょう。

  • ユーザーがダイアログ操作を行った場合にダイアログを閉じて結果を送る
  • ダイアログの開く指示を受けてダイアログを開く

これは違和感はないと思いますが、Reactでは複数のコンポーネントが関心を持つステートは、共通の先祖かそれよりも上位のコンポーネントが持つことになります。これはReduxとかJotaiとかRecoilを使ってもそうです。親は子ダイアログを開きたいので、開閉ステートの管理は親が持つことになります。

そうなると親側でステートをuseState()で作成し、それを変更したりというのも必要になりますが、そういうのはカスタムフックでまとめれば良さそうです。

以前のブログ記事でも紹介したようなDFD風の図で、親の関心が最小になるような構成を考えて見ました。

ダイアログの開閉状態はカスタムフックの中に閉じ込められたので、親コンポーネントと子コンポーネントはそれぞれ必要最低限の関心ごとにのみ触れれば良い状況がつくれそうです。

名称未設定ファイル.drawio.png

実装

useOpenerというカスタムフックを作ってみます。呼び出しもとのイメージはこんな感じです。変更はカスタムフックに渡すコールバックで受け取ります。今回は確認ダイアログなので、booleanの値を受け取っていますが、ここは呼び出すダイアログによってはテキストかもしれないし、はジェネリクスの型パラメータにしたいですね。

ダイアログを開くボタンに渡すコールバックや、子コンポーネントで必要な情報一式がカスタムフックのレスポンスには含まれています。これをこのまま子コンポーネントに渡します。

App.tsx
import { useOpener } from "./opener"
import { ConfirmDialog } from "./Dialog"

function App() {
const callback = useCallback((v: boolean) => {
console.log(`選択された: ${v}`)
}, [])

const [open, opener] = useOpener(callback)

return (
<>
<button className="btn btn-primary" onClick={open}>Open Dialog</button>
<ConfirmDialog message={"サンプル"} opener={opener}/>
</>
)
}

カスタムフックは次のような実装です。

opener.ts
import { useCallback, useState } from "react"

export function useOpener<T>(callback?: (v: T, isOpen: boolean) => void): [()=>void, {isOpen: boolean, close: ()=>void, callback:(isOpen: boolean, v: T)=>void}] {
const [ isOpen, setIsOpen ] = useState(false);

const cb = useCallback((isOpen: boolean, v: T) => {
if (callback) {
callback(v, isOpen)
}
}, [callback])

const open = useCallback(() => {
setIsOpen(true);
}, [])

const close = useCallback(() => {
setIsOpen(false);
}, [])

return [
// 親コンポーネント向け
open,
// 子コンポーネント向け
{
isOpen,
close,
callback: cb,
}
]
}

export type Opener<T> = ReturnType<typeof useOpener<T>>[1]

最後にダイアログの実装です。カスタムフックの情報からダイアログのオープンが必要であれば<dialog>showModal()を呼び出してモーダルを開きます。ダイアログ操作でダイアログを閉じた場合は<dialog>を閉じつつ、再度呼べるようにカスタムフックのステートを閉じるに設定します。また、カスタムフック作成時に渡されたコールバックを呼びます。

カスタムフックを媒介させることで、親と子の結合はだいぶ弱くできました。すくなくとも、内部実装を知らないと使いにくいrefのようなものを親コンポーネントから除外できたのは大きいでしょう。

Dialog.tsx
import { useRef, useEffect, useCallback } from "react"
import type { Opener } from "./opener"

export function ConfirmDialog({ message, opener }: {message: string, opener: Opener<boolean>}) {
const dialog = useRef<HTMLDialogElement>(null);
const { close, isOpen, callback } = opener

useEffect(() => {
if (isOpen) {
dialog.current?.showModal()
}
return () => {
close();
}
}, [isOpen])

const ok = useCallback(() => {
callback(false, true)
close()
dialog.current?.close()
}, [])

const ng = useCallback(() => {
callback(false, false)
close()
dialog.current?.close()
}, [])

return (
<dialog ref={dialog}>
<div className="card card-compact w-96 bg-base-100 shadow-xl p-1">
<div className="card-body">
<h2 className="card-title">{message}</h2>
</div>
<div className="card-actions justify-end">
<button className="btn btn-primary" onClick={ok}>OK</button>
<button className="btn btn-secondary" onClick={ng}>NG</button>
</div>
</div>
</dialog>
)
}

実際に表示してみたのが次のものになります。daisyUIを使っています。

スクリーンショット_2024-01-26_19.48.05.png

アラートも表示してみる

今回のカスタムフック本体は単にbooleanの開閉状態を持っているだけでした。つまり、子コンポーネントはダイアログ以外にも、ドロワーやアラートなんかの表示にも使えます。

実際にアラート表示としてそのまま使ってみましょう。アラートは表示されたら勝手に消えるものなので、終了のコールバックを受ける必要はありません。

App.tsx
import { useOpener } from "./opener"
import { Alert } from "./Alert"

function App() {
const [openAlert, alertOpener] = useOpener()

return (
<>
<button className="btn btn-secondary" onClick={openAlert}>Open Alert</button>
<Alert opener={alertOpener}>メッセージ</Alert>
</>
)
}

export default App

実装してみたアラートがこんな感じです。表示されたらタイマーで5秒後にクローズしています。

Alert.tsx
import { useEffect, ReactNode } from "react"
import type { Opener } from "./opener"

export function Alert({children, opener}: {children : ReactNode, opener: Opener<void>}) {
const { close, isOpen } = opener

useEffect(() => {
if (isOpen) {
setTimeout(() => {
close()
}, 5000)
}
}, [isOpen])

return (
isOpen ?
<div role="alert" className="alert alert-error fixed bottom-2 left-1 right-1">
<span>{children}</span>
</div> : null
)
}

かんたんですね。

スクリーンショット_2024-01-26_20.07.00.png

まとめ

Reactは状態管理を複雑にしようとおもえば結構複雑にできてしまいますが、それぞれのコンポーネントで必要な関心ごとはどれか、というのを考えて、それらのみに触れれば良い状況を作ることで、かなりシンプルにできます。パズルみたいで楽しいですよね。スクリーンリーダー等を考えれば、ダイアログはネイティブなタグの<dialog>を使うべきですが、このAPIが命令的でReactとの相性が良くない(コードが長くなりがち)というのも回避できました。
状態を整理してお互いの依存のないカスタムフックにできたので、当初の予定のダイアログ以外のアラートにも応用ができました。