[React Router/TypeScript/Material-UI] モーダルにURLを付与する

8 min read読了の目安(約7400字

React Routerで以下のようなモーダルを用意したいと思っていたところ、ドンピシャの実装例がドキュメントに存在しました。

  • モーダルに個別のURLを付与したい。
  • 別のタブでURLを指定してアクセスした場合はモーダルではなく通常のページで表示したい。

React Routerのモーダル実装例

このサンプルコードをベースに、TypeScriptとMateril-UIを使って実装してみました。

本記事は重要な部分の抜粋のみです。以下のGitHubリポジトリに全コードがあります。

https://github.com/urawa72/react-samples/tree/main/modal-url-sample

準備

Node.jsとyarnは以下のバージョンを利用しています。

$ node -v
v14.15.4

$ yarn -v
1.22.10

Create React AppでTypeScriptテンプレートを利用したサンプルアプリを作成します。

$ npx create-react-app modal-url-sample --template typescript

React Router, Material-UIをインストールしておきます。

$ yarn add react-router-dom @material-ui/{core,icons}

サンプルアプリの実装

まずはモーダル用のコンポーネントを用意します。Material-UIのCustomized dialogsのサンプルコードを少し加工します。useParamsでURLからパスパラメータであるidを取得して表示内容に利用します(実際のアプリケーションではこのidを使用してAPIをたたいてデータを取得する想定です)。

src/components/SampleModal.tsx
const SampleModal: React.FC = () => {
  const history = useHistory();
  const { id } = useParams<{ id: string }>();
  const [open, setOpen] = React.useState(true);

  const handleClose = () => {
    history.goBack();
    setOpen(false);
  };

  return (
    <Dialog
      onClose={handleClose}
      aria-labelledby="customized-dialog-title"
      open={open}>
      <DialogTitle id="customized-dialog-title" onClose={handleClose}>
        Sample Modal Id: {id}
      </DialogTitle>
      <DialogContent dividers>
        <Typography gutterBottom>
          {[...Array(50)].map(() => `This is a sample modal ${id}!`)}
        </Typography>
      </DialogContent>
      <DialogActions>
        <Button autoFocus onClick={handleClose} color="primary">
          Save changes
        </Button>
      </DialogActions>
    </Dialog>
  );
};

export default SampleModal;

モーダルを表示するためのボタンを並べるページも用意します。<Buttun>コンポーネントのcomponentにReact RouterのLinkを設定しtopathnameに遷移先のパスを設定することで、ボタン押下による画面遷移が実装できます。また、state: { background: location}は「コンテンツをモーダルとして表示するか、通常のページとして表示するか」の判定に使用されるため、重要な設定値です。

pages/Samples.tsx
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';

const data = [
  { id: 1, title: 'sample modal1', content: 'This is a sample modal 1!' },
  { id: 2, title: 'sample modal2', content: 'This is a sample modal 2!' },
  { id: 3, title: 'sample modal3', content: 'This is a sample modal 3!' },
  { id: 4, title: 'sample modal4', content: 'This is a sample modal 4!' },
];

const Samples: React.FC = () => {
  const location = useLocation();
  const buttonList = data.map((d) => (
    <Button
      variant="outlined"
      color="primary"
      component={Link}
      key={d.id}
      to={{
        pathname: `/samples/${d.id}`,
        state: { background: location },
      }}>
      Modal Button {d.id}
    </Button>
  ));
  return (
    <>
      <Typography variant="h4">Modal Samples</Typography>
      {buttonList}
    </>
  );
};

export default Samples;

次に、通常のページで表示する時用のコンポーネントを用意します。表示する内容はモーダルのタイトル・コンテンツと同様です。

src/components/SampleView.tsx
import React from 'react';
import { useParams } from 'react-router-dom';
import Typography from '@material-ui/core/Typography';

const SampleView: React.FC = () => {
  const { id } = useParams<{ id: string }>();

  return (
    <>
      <Typography variant="h4">Sample Modal Id: {id}</Typography>
      <Typography gutterBottom>
        {[...Array(50)].map(() => `This is a sample modal ${id}!`)}
      </Typography>
    </>
  );
};

export default SampleView;

最後に、ルーティングの設定をします。useLocation()locationを取得し、さらにlocationからstate.backgroundを取得します。そして、<Switch>コンポーネントのlocationというプロパティに、先ほど取得したlocationbackgroundを設定します。backgourndを優先して使用するためにlocation={background || location}とします。

このlocationbackgroundがルーティングにどのように影響するかはの説明は後回しにし、一旦これで動作確認します。

src/App.tsx
import React from 'react';
import * as H from 'history';
import { Route, Switch, useLocation } from 'react-router-dom';
import Top from './pages/Top';
import Samples from './pages/Samples';
import SampleModal from './components/SampleModal';
import SampleView from './components/SampleView';

const App: React.FC = () => {
  const location = useLocation<{ background: H.Location }>();
  const background = location.state?.background;

  return (
    <>
      <Switch location={background || location}>
        <Route exact path="/">
          <Top />
        </Route>
        <Route exact path="/samples">
          <Samples />
        </Route>
        <Route exact path="/samples/:id">
          <SampleView />
        </Route>
      </Switch>
      {background && (
        <Route exact path="/samples/:id">
          <SampleModal />
        </Route>
      )}
    </>
  );
};

export default App;

動作確認

/samples画面にアクセスし、適当なボタンを押下するとモーダルが表示されます。また、このモーダルを表示したときにアドレスバーのURLがモーダル用のURLに変わっています。これは意図通りの挙動です。

次に、このモーダルのURLをコピーして別タブで開きます。すると、モーダルではなく通常のページで上記のモーダルと同様のコンテンツが表示されます。これも意図通りの挙動です。

<Switch>locationについて

  • モーダルに個別のURLを付与したい。
  • 別のタブでURLを指定してアクセスした場合はモーダルではなく通常のページで表示したい。

上記を実装するための重要な点は、src/App.tsx<Switch>コンポーネントで指定しているlocationプロパティです。locationプロパティを利用することで、ブラウザのアドレスバーに表示されているURLとは異なるURLを用いてルーティングを行うことができます。

A location object to be used for matching children elements instead of the current history location (usually the current browser URL).

参考: location

/samplesにアクセスしたときのlocationbackgroundは以下の内容です。

location: {pathname: "/samples", search: "", hash: "", state: undefined, key: "bghv4g"}
background: undefined

/samples/1でモーダルを表示したときは以下になります。

location: {pathname: "/samples/1", state: { background: {pathname: "/samples", search: "", hash: "", state: undefined, key: "bghv4g"} }, search: "", hash: "", key: "w8evpp"}
background: {pathname: "/samples", search: "", hash: "", state: undefined, key: "bghv4g"}

ルーティングのコードだけ抜粋して再掲します。

モーダル表示時、backgroundにはアドレスバーに現在表示されているURL(モーダルのURL)のパスではなく、モーダルの表示元ページのURLのパスが設定されているため、<Switch>内では/samplesがモーダルの背景ページとして描画されます(既に表示されているので画面の変化はない)。

src/App.tsx
// backgroundが存在する場合はbackgroundを元にルーティングが行われる
<Switch location={background || location}>
  <Route exact path="/">
    <Top />
  </Route>
  <Route exact path="/samples">
    <Samples />
  </Route>
  <Route exact path="/samples/:id">
    <SampleView />
  </Route>
</Switch>

<Switch>内の判定終了後、さらに<Switch>外に存在するルーティングも処理されます。backgroundが存在する時、<SampleModal />が描画されます。これがモーダルの表示を担っています。

src/App.tsx
{background && (
  <Route exact path="/samples/:id">
    <SampleModal />
  </Route>
)}

別のタブでURLを指定してアクセスした場合、backgroundは存在しないためlocation(アドレスバーに表示されている/sample/1というパス)を元にルーティングされます。その結果、<SampleView />のみが描画されモーダルは表示されません。

src/App.tsx
<Switch location={background || location}>
  <Route exact path="/">
    <Top />
  </Route>
  <Route exact path="/samples">
    <Samples />
  </Route>
  // このルーティングにマッチする
  <Route exact path="/samples/:id">
    <SampleView />
  </Route>
</Switch>

おわりに

やりたいことに完全にマッチした実装が見つかって良かった…。<Switch>におけるlocationプロパティの挙動を調べるきっかけにもなったので一石二鳥でした。