👷

Modalコンポーネントの実装の問題点と解決方法

2024/05/08に公開

この記事では、Reactでモーダルコンポーネントを実装する方法について説明します。HTMLのdialogタグなどマークアップには触れません。

前提

以下の共通モーダルコンポーネントを使用することを前提にします。詳細な実装は重要でないため、簡易的な例で説明します。

export const BaseModal = ({visible, children}: {visible: boolean; children: ReactNode}) => {
  return <FadeTransition in={visible}>{children}<FadeTransition>
}

FadeTransitionコンポーネントはフェードアニメーションでモーダルを表示するために使用されています。
visible: false であっても、アニメーションを有効にするためにはコンポーネントがマウントされた状態である必要があります。

特定の機能に依存するモーダルコンポーネント

BaseModalコンポーネントを使用して特定の機能に依存するモーダルコンポーネントの実装方法について考察します。

実装例1

propsにモーダルの開閉フラグ(visible)とコンテンツを表示するために必要な情報(requestParams)を受け取る。

export const SampleModal = ({visible, requestParams}: {visible: boolean; requestParams: { id: string}}) => {
  const { data } = useFetchData(requestParams)

  return (
    <BaseModal visible={visible}>
      <div className={
        //...
      }>
        {data && <p>{data.name}</p>}
      </div>
    <BaseModal>
  )
}

問題点

  • 親コンポーネントから渡される requestParams が未確定の場合、SampleModalコンポーネントをマウントできない。
const REDUCER: Reducer<
  { visible: false } | { visible: true; requestParams: { id: string } },
  {}
> = (prevState, action) => {
  //...
}

export const Parent = () => {
  const [state, dispatchState] = useReducer(REDUCER, { visible: false })

  return (
    <>
      <SampleModal
        visible={state.visible}
        requestParams={state.requestParams} // state.requestParamsがoptionalなので指定できない
      />

      {state.visible && ( // state.visible === falseの場合にSampleModalがマウントされず、FadeTransitionが正常に動かない
        <SampleModal
          visible={state.visible}
          requestParams={state.requestParams}
        />
      )}
    </>
  )
}

実装例2

実装例1の requestParams を任意にする。

export const SampleModal = ({visible, requestParams}: {visible: boolean; requestParams?: { id: string}}) => {
  const { data } = useFetchData(requestParams) // useFetchDataは必須パラメータであるrequestParamsが存在しないと使用することができない

  return (
    <BaseModal visible={visible}>
      <div className={
        //...
      }>
        {data <p>{data.name}</p>}
      </div>
    <BaseModal>
  )
}

改善点

  • 実装例1の問題点である requestParams が未確定の場合でも、SampleModalコンポーネントをマウントできるようになった。

問題点

  • requestParams がAPIを呼ぶのに必須のパラメータである場合、useFetchData内で条件分岐が発生することになる。[1]
  • requestParams を受け取れなくなった場合でもコンポーネントが破棄されず、モーダルの再表示時に古い情報が表示される可能性がある。

実装例3

モーダルのラッパーとコンテンツを分離して、親からそれぞれ指定する。

export const Wrapper = ({visible, children}: {visible: boolean; children: ReactNode}) => {
  return (
    <BaseModal visible={visible}>
      <div className={
        //...
      }>
        {children}
      </div>
    </BaseModal>
  )
}

export const Content = ({requestParams}: {requestParams: {id: string}}) => {
  const { data } = useFetchData(requestParams)

  return <p>{data.name}</p>
}

export const SampleModal = {
  Wrapper,
  Content
}
export const Parent = () => {
  const [state, dispatchState] = useReducer(REDUCER, { visible: false })

  return (
    <>
      <SampleModal.Wrapper visible={state.visible}>
        {state.visible && (
          <SampleModal.Content requestParams={state.requestParams} />
        )}
      </SampleModal.Wrapper>
    </>
  )
}

改善点

  • Wrapperコンポーネントは常にマウントされているため、FadeTransitionが正常に動作する。
  • Contentコンポーネントは requestParams が確定した場合のみマウントされる。
  • Contentコンポーネントは requestParams を受け取れなくなったら破棄される。
  • Contentコンポーネントの requestParams は必須項目として扱われるため、コンポーネント内で条件分岐が不要。

問題点

  • Wrapperのchildrenの型が制限できない。
    • Wrapperのchildrenは1つのContentコンポーネントのみと制限したいができない。

実装例4

Wrapperコンポーネントに直接Contentコンポーネントを配置する。Wrapperコンポーネントでは requestParams を任意Propとして受け取り、Contentコンポーネントでは必須Propとして受け取る。

ここではWrapperコンポーネントはSampleModalという名前に変えます。

export const SampleModal = ({
  visible,
  requestParams
}: {
  visible: boolean
  requestParams?: {id: string} // requestParamsがoptionalなのがポイント
}) => {
  return (
    <BaseModal visible={visible}>
      <div className={
        //...
      }>
        {requestParams && <Content />}
      </div>
    </BaseModal>
  )
}

const Content = ({
  requestParams
}: {
  requestParams: {id: string} // ContentコンポーネントはrequestParamsを必須項目として受け取ることでContentコンポーネント内で条件分岐不要となる、またrequestParamsが渡せなくなったらコンポーネント毎破棄される
}) => {
  const { data } = useFetchData(requestParams)
  return <p>{data.name}</p>
}
export const Parent = () => {
  const [state, dispatchState] = useReducer(REDUCER, { visible: false })

  return (
    <>
      <SampleModal {...state} />
    </>
  )
}

改善点

  • Wrapperコンポーネントは常にマウントされているため、FadeTransitionが正常に動作する。
  • Contentコンポーネントは requestParams が確定した場合のみマウントされる。
  • Contentコンポーネントは requestParams を受け取れなくなったら破棄される。
  • Contentコンポーネントの requestParams は必須項目として扱われるため、コンポーネント内で条件分岐が不要。
  • 実装例3で問題となったWrapperコンポーネントのchildrenの型が制限できない問題が解決された。

まとめ

実装例4がよさそうです(実装例3も悪くないと思います)

脚注
  1. もしTanStack Queryを使用していれば、skipToken を使うことで回避できるかもしれませんが、propsで受け取るものはAPIのリクエストパラメータだけとは限りません。 ↩︎

Discussion