ReactでModalを利用したい場合にはReact-Modalなどのライブラリもありますが本文書ではReact Hooksを利用してModalの作成をスクラッチからStep By Stepで確認していきます。スクラッチからModalを作成することでReact HooksだけではなくcreatePortal, useState, useEffect, useRefなどReactの基本的な機能の理解を深めることができます。また、モーダルウィンドウが表示されている時に背景のスクロールを停止する”body-scroll-lock”ライブラリの設定も行います。

プロジェクトの作成

create-react-appコマンドを利用してReactプロジェクトの作成を行います。プロジェクトの名前は任意の名前をつけてください。ここではreact-hooks-modalとしています。

Viteを利用してReactプロジェクトの作成を行います。”npm create vite@latest”コマンドを実行するとプロジェクト名、Framework, Variantを聞かれます。ここではプロジェクト名に”react-hooks-modal”とFrameworkに”React”, Variantに”JavaScript”を選択しています。


 % npm create vite@latest

> npx
> create-vite

✔ Project name: … react-hooks-modal
✔ Select a framework: › React
✔ Select a variant: › JavaScript

Scaffolding project in /Users/mac/Desktop/react-hooks-modal...

Done. Now run:

  cd react-hooks-modal
  npm install
  npm run dev

プロジェクトの作成が完了後、作成したプロジェクトに移動して”npm install”コマンドでライブラリのインストールを行います。


 % cd react-hooks-modal
 % npm install

デフォルトのスタイルを解除するためにmain.jsファイルでindex.cssのimportをコメントします。


import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
// import './index.css'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

プロジェクトフォルダのsrcフォルダにあるApp.jsファイルを作成して下記のコードを記述します。


function App() {
  return (
    <div style={{ margin: '2em;' }}>
      <h1>React Hooksでモーダルウィンドウを作成</h1>
    </div>
  );
}

export default App;

npm run devコマンドを実行して開発サーバの起動を行います。


% npm run dev

> react-hooks-modal@0.0.0 dev
> vite


  VITE v5.3.4  ready in 720 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

ブラウザを起動して開発サーバにアクセスを行い下記の画面が表示されることを確認します。React Hooksでモーダルウィンドウを作成するための事前準備は完了です。

作業前の表示
作業前の表示

React Hooksを利用したモーダルの作成

これからHooksを利用してモーダルを作成します。React Hooksがなにかわからない人はこちらの記事が参考になります。

useModal Hookの作成

srcフォルダ内にhooksフォルダを作成してuseModal.jsxファイルを作成します。

useModal関数の中にModal関数を追加します。Modal関数ではブラウザに描写する内容を設定します。設定したModal関数をreturnすることでuseModal関数を実行した際の戻り値として利用することができます。


const useModal = () => {
  const Modal = () => {
    return <h2>Modal</h2>;
  };

  return { Modal };
};

export default useModal;

App.jsxファイルでuseModalをimportしてuseModal関数を実行して戻り値のModal関数を取得します。取得したModal関数をコンポーネントとしてModalタグで利用することができます。


import useModal from './hooks/useModal';

function App() {
  const { Modal } = useModal();
  return (
    <div style={{ margin: '2em' }}>
      <h1>React Hooksでモーダルウィンドウを作成</h1>
      <Modal />
    </div>
  );
}

export default App;

ブラウザで確認するとuseModal.jsxファイルで定義したModal関数の内容が表示されます。useModal Hookを作成することができました。useModal Hookを利用することでModal関数に設定した内容をブラウザ上に表示することができます。

useModal Hookを利用してブラウザ上に描写
useModal Hookを利用してブラウザ上に描写

childrenの設定

Modal関数の中にブラウザ上に表示させる内容を直接記述していましたが外側からHTMLの要素を渡せるようにchildren propsを利用します。


const Modal = ({ children }) => {
  return <>{children}</>;
};

App.jsxファイルのModalタグの中にHTML要素を挿入します。


import useModal from './hooks/useModal';

function App() {
  const { Modal } = useModal();

  return (
    <div style={{ margin: '2em' }}>
      <h1>React Hooksでモーダルウィンドウを作成</h1>
      <Modal>
        <h2>Content from children</h2>
      </Modal>
    </div>
  );
}

export default App;

ブラウザ上ではModalタグの中に挿入したHTML要素が表示されます。

Modalタグで挿入したコンテンツの確認
Modalタグで挿入したコンテンツの確認

表示/非表示設定

Modalコンポーネントの内容をブラウザ上に表示することができたので、表示・非表示できる機能の追加を行います。表示・非表示にはuseState Hookを利用します。


import { useState } from 'react';

const useModal = () => {
  const [show, setShow] = useState(false);

  const Modal = ({ children }) => {
    if (!show) return null;
    return <>{children}</>;
  };

  return { Modal };
};

export default useModal;

showのデフォルト値をfalseにした場合はModal関数はnullを戻すのでブラウザ上には何も表示されません。デフォルト値をtrueにした場合にはブラウザ上に”cotent from children”の文字列が表示されます。

useStateの初期値ではなく表示・非表示を制御できるようにuseModal HookにopenModal, closeModal関数を追加します。追加した関数はModalと一緒にreturnに設定します。useModal Hookを実行することでModalだけでなくopenModal, closeModalも戻り値として戻されるのでそれらを別のコンポーネントから利用することできます。


import { useState } from 'react';

const useModal = () => {
  const [show, setShow] = useState(false);

  const openModal = () => {
    setShow(true);
  };

  const closeModal = () => {
    setShow(false);
  };

  const Modal = ({ children }) => {
    if (!show) return null;
    return <>{children}</>;
  };

  return { Modal, openModal, closeModal };
};

export default useModal;

App.jsxではuseModal HookからopenModalとcloseModal関数をimportします。”Open”と”Close”のボタンを追加して各ボタンにClickイベントを設定してimportした関数を指定します。


import useModal from './hooks/useModal';

function App() {
  const { Modal, openModal, closeModal } = useModal();

  return (
    <div style={{ margin: '2em' }}>
      <h1>React Hooksでモーダルウィンドウを作成</h1>
      <div>
        <button onClick={openModal}>Open</button>
      </div>
      <Modal>
        <h2>Content from Children</h2>
        <button onClick={closeModal}>Close</button>
      </Modal>
    </div>
  );
}

export default App;

ブラウザからアクセスするとデフォルトのshowの値はfalseに設定しているのでModalの内容は表示されません。

Modalが表示される前
Modalが表示される前

“Open”ボタンをクリックします。クリックするとopenModal関数によりshowの値がfalseからtrueに変更されるためModalの内容が表示されます。

openボタンでModalの内容が表示
openボタンでModalの内容が表示

“close”ボタンをクリックするとModalの内容が非表示となります。useModal Hookを利用することで表示・非表示の切り替えを行えるようになりました。

Overlayの設定

モーダルウィンドウではモーダルで表示させる内容を背景とは区別するため背景を薄暗い色に設定しモーダルは中央付近に表示させることが一般的です。背景はOverlay(オーバーレイ)を呼ばれます。Overlayの設定を行います。

背景は画面全体を覆うためpositionをfixedにtop, bottom, left, rightの値を0に設定します。flexboxを利用して中央にコンテンツを表示するようにしています。コンテンツをブラウザの中央に表示させたくない場合はjustify-contentとalign-itemsの値で調整できます。


const Modal = ({ children }) => {
  if (!show) return null;
  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <div
        style={{
          position: 'fixed',
          top: 0,
          bottom: 0,
          left: 0,
          right: 0,
          backgroundColor: 'gray',
          opacity: '0.5',
        }}
      ></div>
      <div style={{ position: 'relative' }}>{children}</div>
    </div>
  );
};

overlayの背景にはグレーを設定し、opacityは0.5を設定しています。背景色と透明度はここで変更することができます。

ブラウザ上の”Open”ボタンをクリックすると画面中央にAppコンポーネントのModalタグに挿入したコンテンツが表示されていることが確認できます。

モーダルウィンドウの表示
モーダルウィンドウの表示

“close”ボタンも有効なのでクリックするとモーダルウィンドウは非表示となります。

コンテンツのstyle設定

表示させているコンテンツの背景色を設定していないためOverlayの背景色と同色になっています。コンテンツのスタイルを設定してコンテンツの内容が際立つようにしていきます。

コンテンツのstyle設定はModalタグに挿入したHTML要素で行うことができます。背景色を白に設定します。


import useModal from './hooks/useModal';

function App() {
  const { Modal, openModal, closeModal } = useModal();

  return (
    <div style={{ margin: '2em' }}>
      <h1>React Hooksでモーダルウィンドウを作成</h1>
      <div>
        <button onClick={openModal}>Open</button>
      </div>
      <Modal>
        <div style={{ backgroundColor: 'white' }}>
          <h2>Content from Children</h2>
          <button onClick={closeModal}>Close</button>
        </div>
      </Modal>
    </div>
  );
}

export default App;

コンテンツの背景色を設定することでコンテンツが目立つようになりました。

コンテンツの背景色の設定
コンテンツの背景色の設定

現在の設定ではコンテンツの幅はコンテンツの内容に依存しているのでwidthやheight、paddingを設定することでコンテンツの大きさを変更することができます。width, height, paddingとborder-radiusの設定を行います。


<Modal>
  <div
    style={{
      backgroundColor: 'white',
      width: '300px',
      height: '200px',
      padding: '1em',
      borderRadius: '15px',
    }}
  >
    <h2>Content from Children</h2>
    <button onClick={closeModal}>Close</button>
  </div>
</Modal>

指定したstyleで表示されることがわかります。

width, height,paddingの設定
cwidth, height,paddingの設定

これだけの設定でモーダルウィンドウを作成することができました。

createPortalの設定

現在の設定でもモーダルウィンドウを表示することができますが”Open”ボタンをクリックするとModalタグを設定した場所に要素が追加され、モーダルウィンドウが表示されます。

言葉だけの説明では分かりにくいのでModalタグの下に要素を追加します。


<div style={{ margin: '2em' }}>
  <h1>React Hooksでモーダルウィンドウを作成</h1>
  <div>
    <button onClick={openModal}>Open</button>
  </div>
  <Modal>
    <div
      style={{
        backgroundColor: 'white',
        width: '300px',
        height: '200px',
        padding: '1em',
        borderRadius: '15px',
      }}
    >
      <h2>Content from Children</h2>
      <button onClick={closeModal}>Close</button>
    </div>
  </Modal>
  <div>メインコンテンツ</div>
</div>

追加後”open”ボタンをクリックしてブラウザのデベロッパーツールの要素を確認します。説明した通りModalタグを設定した場所にモーダルを構成する要素が追加されています。

モーダルウィンドウの要素の追加
モーダルウィンドウの要素の追加

現在は動作確認のためシンプルな構成なのでモーダルウィンドウは問題なく動作していますが、コンポーネントの組み合わせにより複雑に要素が重なり合った状態ではz-indexを設定しても正しく表示されない場合があります。そのような要素の重なりの問題が発生しないためcreatePortalによってモーダルウィンドウを表示する場所を指定します。Viteではプロジェクトディレクトリの直下のindex.htmlファイルでReactのコードを挿入する場所として<div id=”root”></div>が設定されているのでidにrootを持つ要素をcreatePortalの場所として指定します。<div id=”portal”></div>といった要素をindex.htmlファイルに追加することも可能です。

createPortalの構文は下記の通りで、第一引数に表示させたい要素、第二引数に表示させたい場所の要素を指定するだけです。


createPortal(
Hello World
,document.getElementById('root'))

Modal.jsxでcreatePortalを利用します。createPortalはreact-domからimportします。


import { useState } from 'react';
import { createPortal } from 'react-dom';

const useModal = () => {
  const [show, setShow] = useState(false);

  const openModal = () => {
    setShow(true);
  };

  const closeModal = () => {
    setShow(false);
  };

  const Modal = ({ children }) => {
    if (!show) return null;
    return createPortal(
      <div
        style={{
          position: 'fixed',
          top: 0,
          left: 0,
          right: 0,
          bottom: 0,
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
        }}
      >
        <div
          style={{
            position: 'fixed',
            top: 0,
            bottom: 0,
            left: 0,
            right: 0,
            backgroundColor: 'gray',
            opacity: '0.5',
          }}
        ></div>
        <div style={{ position: 'relative' }}>{children}</div>
      </div>,
      document.getElementById('root')
    );
  };

  return { Modal, openModal, closeModal };
};

export default useModal;

設定後に再度ブラウザのデベロッパーツールの要素を利用してモーダルウィンドウの要素が追加されている場所を確認します。

先ほどは”Open”ボタンの下でメインコンテンツの上に挿入されていましたがidにrootを持つdiv要素の下に追加されていることがわかります。

モーダルウィンドウの要素の場所
モーダルウィンドウの要素の場所

Modalタグをh1タグの上に配置したとしても表示される場所はidにrootを持つdiv要素の下になります。createPortalによりモーダルウィンドウの要素が追加される場所を指定できるようになりました。

背景のスクロールを停止する

現在の設定ではコンテンツが少ないのでブラウザのスクロールバーが表示されることはありません。背景のスクロールとはどういうことを意味しているのかを確認するためにブラウザのスクロールバーが表示されるように設定を行います。

メインコンテンツのdiv要素にheightを設定し高さがわかるように背景色を設定します。


<div style={{ height: '2000px', backgroundColor: '#ddd' }}>
  メインコンテンツ
</div>

ブラウザで確認するとブラウザの右側にスクロールバーが右側に表示されることが確認できます。

スクロールバーの表示
スクロールバーの表示

“Open”ボタンをクリックしてモーダルウィンドウを表示してスクロールを行ってください。モーダルは中央に表示されていますが背景がスクロールされていることがわかります。これが背景のスクロールです。

背景がスクロール
背景がスクロール

背景のスクロールがどのようなものか理解できたのでモーダルウィンドウが表示されている間は背景のスクロールを停止するためにbody-scroll-lockライブラリを利用します。

npmコマンドでインストールを行います。


 % npm install body-scroll-lock

body-scroll-lockからimportする3つの関数(disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks)を利用します。

disableBodyScrollの引数には要素とオプションを指定することができbodyのスクロールを停止します。要素の指定は必須です。enableBodyScrollの引数には要素を指定することができ停止したスクロールを再度有効にします。clearAllBodyScrollLocksは複数の要素でdisableBodyScrollを設定した場合でもすべて解除します。

disableBodyScrollの指定した要素はiOSのデバイスのみ追加処理を行なっています。それ以外のデバイスではbodyのスクロールを停止する処理のみ行います。
fukidashi

要素を指定するためにuseRef Hookを利用します。モーダルウィンドウのコンテンツの要素をuseRef Hookを利用してその要素を取得します。useRefで定義したcontentRefのcurrentプロパティに要素が保存されます。保存される要素は<div style=”position:relative”></div>です。


const Modal = ({ children }) => {
  const contentRef = useRef(null);
  if (!show) return null;
  return createPortal(
    <div
      style={{
//略
      }}
    >
      <div
        style={{
//略
        }}
      ></div>
      <div style={{ position: 'relative' }} ref={contentRef}>
        {children}
      </div>
    </div>,
    document.getElementById('root')
  );
};

body-scroll-lockの制御はuseEffectの中で行います。contentRef.currentに要素が保存されていない場合には何も行いません。要素が存在し、showがtrueの場合にdisableBodyScrollでbodyのスクロールを停止します。showがfalseの場合はスクロールを再開します。Modalコンポーネントをアンマウントする際はすべてのスクロールの設定を解除しています。


useEffect(() => {
  if (contentRef.current === null) return;

  if (show) {
    disableBodyScroll(contentRef.current, {
      reserveScrollBarGap: true,
    });
  } else {
    enableBodyScroll(contentRef.current);
  }

  return () => {
    clearAllBodyScrollLocks();
  };
}, [show, contentRef]);

useEffectの依存関係にshowを設定すると”React Hook useEffect has an unnecessary dependency: ‘show’. Either exclude it or remove the dependency array. Outer scope values like ‘show’ aren’t valid dependencies because mutating them doesn’t re-render the component”のWarningが表示されるのでModalのpropsとしてshowを受け取れるようにします。useModal関数ではreturnにshowを加えます。


import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
  disableBodyScroll,
  enableBodyScroll,
  clearAllBodyScrollLocks,
} from 'body-scroll-lock';

const useModal = () => {
  const [show, setShow] = useState(false);

  const openModal = () => {
    setShow(true);
  };

  const closeModal = () => {
    setShow(false);
  };

  const Modal = ({ children, show }) => {
    const contentRef = useRef(null);

    useEffect(() => {
      if (contentRef.current === null) return;

      if (show) {
        disableBodyScroll(contentRef.current);
      } else {
        enableBodyScroll(contentRef.current);
      }

      return () => {
        clearAllBodyScrollLocks();
      };
    }, [show, contentRef]);

    if (!show) return null;

    return createPortal(
      <div
        style={{
//略
        }}
      >
        <div
          style={{
//略
          }}
        ></div>
        <div style={{ position: 'relative' }} ref={contentRef}>
          {children}
        </div>
      </div>,
      document.getElementById('root')
    );
  };

  return { Modal, openModal, closeModal, show };
};

export default useModal;

useModalでの設定が完了したのでuseModal Hookからshowのimportを追加してModalにpropsでshowを渡します。


import useModal from './hooks/useModal';
import './App.css';

function App() {
  const { Modal, openModal, closeModal, show } = useModal();
  return (
    <div style={{ margin: '2em' }}>
      <Modal show={show}>
        <div
          style={{
            backgroundColor: 'white',
            width: '300px',
            height: '200px',
            padding: '1em',
            borderRadius: '15px',
          }}
        >
          <h2>Content from Children</h2>
          <button onClick={closeModal}>Close</button>
        </div>
      </Modal>
      <h1>React Hooksでモーダルウィンドウを作成</h1>
      <div>
        <button onClick={openModal}>Open</button>
      </div>

      <div style={{ height: '2000px', backgroundColor: '#ddd' }} id="main">
        メインコンテンツ
      </div>
    </div>
  );
}

export default App;

設定完了後、”Open”ボタンをクリックすると表示されていたブラウザのスクロールバーが消え、背景のスクロールができなくなります。

スクロールの停止
スクロールの停止

“Close”ボタンをクリックすると再度スクロールが可能になります。

React Hooksを利用してモーダルウィンドウを作成することができました。それほど複雑なコードではありませんがこれらの設定を通してReact Hook, useState, useEffectなどさまざまなことを関わること学ぶことができました。